From 0043d8333049ba63363c6d86135dbca5869c9b37 Mon Sep 17 00:00:00 2001 From: riwiwa Date: Thu, 5 Feb 2026 00:20:42 -0800 Subject: [PATCH] cleaned up project structure and optimized lastfm and spotify migration --- importsongs/importsongs.go | 436 -------------------------------- main.go | 12 +- migrate/lastfm.go | 236 +++++++++++++++++ migrate/migrate.go | 55 ++++ migrate/spotify.go | 332 ++++++++++++++++++++++++ static/assets/default.png | Bin 0 -> 7915 bytes templates/create_account.gohtml | 2 +- templates/history.gohtml | 2 +- templates/login.gohtml | 7 +- templates/profile.gohtml | 14 +- web/web.go | 220 ++++++++++------ 11 files changed, 795 insertions(+), 521 deletions(-) delete mode 100644 importsongs/importsongs.go create mode 100644 migrate/lastfm.go create mode 100644 migrate/migrate.go create mode 100644 migrate/spotify.go create mode 100644 static/assets/default.png diff --git a/importsongs/importsongs.go b/importsongs/importsongs.go deleted file mode 100644 index 1f4fd04..0000000 --- a/importsongs/importsongs.go +++ /dev/null @@ -1,436 +0,0 @@ -package importsongs - -import ( - "archive/zip" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5" -) - -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) error { - if !DbExists() { - err := CreateDB() - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err) - 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) - return err - } - if platform == spotify { - type Track struct { - Timestamp string `json:"ts"` - Platform string `json:"-"` - Played int `json:"ms_played"` - Country string `json:"-"` - IP string `json:"-"` - 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:"-"` - Episode string `json:"-"` - Show string `json:"-"` - EpisodeURI string `json:"-"` - Audiobook string `json:"-"` - AudiobookURI string `json:"-"` - AudiobookChapterURI string `json:"-"` - AudiobookChapter string `json:"-"` - ReasonStart string `json:"-"` - ReasonEnd string `json:"-"` - Shuffle bool `json:"-"` - Skipped bool `json:"-"` - Offline bool `json:"-"` - OfflineTimestamp int `json:"-"` - Incognito bool `json:"-"` - } - var tracks []Track - err := json.Unmarshal(jsonData, &tracks) - if err != nil { - fmt.Fprintf(os.Stderr, "Cannot unmarshal %s: %v\n", jsonFile, err) - return 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, - ) - } - } - } - return nil -} - -func AddDirToDB(path string, platform int) error { - dirs, err := os.ReadDir(path) - if err != nil { - fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", path, err) - return err - } - for _, dir := range dirs { - subPath := filepath.Join( - path, - dir.Name(), - "Spotify Extended Streaming History", - ) - entries, err := os.ReadDir(subPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", subPath, err) - return 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) - err = JsonToDB(jsonFilePath, platform) - if err != nil { - fmt.Fprintf(os.Stderr, - "Error adding json data (%s) to muzi database: %v", jsonFilePath, err) - return err - } - } - } - return nil -} - -func ImportLastFM(username string, apiKey string) error { - if !DbExists() { - err := CreateDB() - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err) - 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) - } - - resp, err := http.Get( - "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + - username + "&api_key=" + apiKey + "&format=json&limit=1", - ) - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting LastFM http response: %v\n", err) - return err - } - type Response struct { - Recenttracks struct { - Track []struct { - Artist struct { - Mbid string `json:"-"` - Text string `json:"#text"` - } `json:"artist"` - Streamable string `json:"-"` - Image []struct { - Size string `json:"-"` - Text string `json:"-"` - } `json:"-"` - Mbid string `json:"-"` - Album struct { - Mbid string `json:"-"` - Text string `json:"#text"` - } `json:"album"` - Name string `json:"name"` - Attr struct { - Nowplaying string `json:"nowplaying"` - } `json:"@attr,omitempty"` - URL string `json:"-"` - Date struct { - Uts string `json:"uts"` - Text string `json:"-"` - } `json:"date"` - } `json:"track"` - Attr struct { - PerPage string `json:"-"` - TotalPages string `json:"totalPages"` - Page string `json:"page"` - Total string `json:"-"` - User string `json:"-"` - } `json:"@attr"` - } `json:"recenttracks"` - } - var data Response - json.NewDecoder(resp.Body).Decode(&data) - totalPages, err := strconv.Atoi(data.Recenttracks.Attr.TotalPages) - if totalPages%100 != 0 { - totalPages = totalPages / 100 - totalPages++ - } else { - totalPages = totalPages / 100 - } - - for i := 1; i <= totalPages; i++ { - resp, err := http.Get( - "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + - username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + - strconv.Itoa( - i, - ), - ) - if err != nil { - fmt.Fprintf(os.Stderr, "Error getting LastFM http response: %v\n", err) - return err - } - json.NewDecoder(resp.Body).Decode(&data) - 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 { - fmt.Fprintf(os.Stderr, "Error parsing string for int: %v\n", err) - return err - } - ts := time.Unix(unixTime, 0) - _, err = conn.Exec( - context.Background(), - `INSERT INTO history (timestamp, song_name, artist, album_name, - ms_played) VALUES ($1, $2, $3, $4, $5);`, - ts, - data.Recenttracks.Track[j].Name, - data.Recenttracks.Track[j].Artist.Text, - data.Recenttracks.Track[j].Album.Text, - 0, - ) - if err != nil { - fmt.Fprintf( - os.Stderr, - "Couldn't add track to muzi DB (%s): %v\n", - (data.Recenttracks.Track[j].Artist.Text + " - " + - data.Recenttracks.Track[j].Name), err) - } - } - } - return nil -} - -func ImportSpotify() error { - path := filepath.Join(".", "imports", "spotify", "zip") - targetBase := filepath.Join(".", "imports", "spotify", "extracted") - entries, err := os.ReadDir(path) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading path: %s: %v\n", path, err) - return err - } - for _, f := range entries { - _, err := zip.OpenReader(filepath.Join(path, f.Name())) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", - filepath.Join(path, f.Name()), err) - continue - } - fileName := f.Name() - fileFullPath := filepath.Join(path, fileName) - fileBaseName := fileName[:(strings.LastIndex(fileName, "."))] - targetDirFullPath := filepath.Join(targetBase, fileBaseName) - - err = Extract(fileFullPath, targetDirFullPath) - if err != nil { - fmt.Fprintf(os.Stderr, "Error extracting %s to %s: %v\n", - fileFullPath, targetDirFullPath, err) - return err - } - } - err = AddDirToDB(targetBase, spotify) - if err != nil { - fmt.Fprintf(os.Stderr, - "Error adding directory of data (%s) to muzi database: %v\n", - targetBase, err) - return err - } - return nil -} - -func Extract(path string, target string) error { - archive, err := zip.OpenReader(path) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", path, err) - return 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), - ) { - err = fmt.Errorf("Invalid file path: %s", filePath) - fmt.Fprintf(os.Stderr, "%v\n", err) - return err - } - 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 { - fmt.Fprintf(os.Stderr, "Error making directory: %s: %v\n", - filepath.Dir(filePath), err) - return err - } - fileToExtract, err := os.OpenFile( - filePath, - os.O_WRONLY|os.O_CREATE|os.O_TRUNC, - f.Mode(), - ) - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err) - return err - } - extractedFile, err := f.Open() - if err != nil { - fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err) - return err - } - if _, err := io.Copy(fileToExtract, extractedFile); err != nil { - fmt.Fprintf( - os.Stderr, - "Error while copying file: %s to: %s: %v\n", - fileToExtract.Name(), - extractedFile, - err, - ) - return err - } - fileToExtract.Close() - extractedFile.Close() - } - return nil -} diff --git a/main.go b/main.go index f2ab8c8..7fdf755 100644 --- a/main.go +++ b/main.go @@ -6,13 +6,13 @@ import ( "os" "path/filepath" - "muzi/importsongs" + "muzi/migrate" "muzi/web" ) func dbCheck() error { - if !importsongs.DbExists() { - err := importsongs.CreateDB() + if !migrate.DbExists() { + err := migrate.CreateDB() if err != nil { fmt.Fprintf(os.Stderr, "Error creating muzi DB: %v\n", err) return err @@ -70,12 +70,12 @@ func main() { username := "" apiKey := "" - fmt.Printf("Importing LastFM data for %s", username) - err = importsongs.ImportLastFM(username, apiKey) + fmt.Printf("Importing LastFM data for %s\n", username) + err = migrate.ImportLastFM(username, apiKey) if err != nil { return } - err = importsongs.ImportSpotify() + err = migrate.ImportSpotify() if err != nil { return } diff --git a/migrate/lastfm.go b/migrate/lastfm.go new file mode 100644 index 0000000..90fa03b --- /dev/null +++ b/migrate/lastfm.go @@ -0,0 +1,236 @@ +package migrate + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/jackc/pgx/v5" +) + +type LastFMTrack struct { + Timestamp time.Time + SongName string + Artist string + Album string +} + +type pageResult struct { + pageNum int + tracks []LastFMTrack + err error +} + +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 ImportLastFM(username string, apiKey string) error { + if !DbExists() { + err := CreateDB() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err) + 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) + } + } + + totalImported := 0 + + resp, err := http.Get( + "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + + username + "&api_key=" + apiKey + "&format=json&limit=100", + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting LastFM HTTP response: %v\n", err) + return err + } + var initialData Response + json.NewDecoder(resp.Body).Decode(&initialData) + totalPages, err := strconv.Atoi(initialData.Recenttracks.Attr.TotalPages) + resp.Body.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing total pages: %v\n", err) + return err + } + fmt.Printf("Total pages: %d\n", totalPages) + + trackBatch := make([]LastFMTrack, 0, 1000) + + pageChan := make(chan pageResult, 20) + + var wg sync.WaitGroup + // use 10 workers + wg.Add(10) + + for worker := range 10 { + go func(workerID int) { + defer wg.Done() + // distrubute 10 pages to each worker + for page := workerID + 1; page <= totalPages; page += 10 { + resp, err := http.Get( + "https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=" + + username + "&api_key=" + apiKey + "&format=json&limit=100&page=" + strconv.Itoa(page), + ) + if err != nil { + pageChan <- pageResult{pageNum: page, err: err} + continue + } + var data Response + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + resp.Body.Close() + pageChan <- pageResult{pageNum: page, err: err} + continue + } + resp.Body.Close() + + 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{ + 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, + }) + } + pageChan <- pageResult{pageNum: page, tracks: pageTracks, err: nil} + } + }(worker) + } + + go func() { + wg.Wait() + close(pageChan) + }() + + batchSize := 500 + + 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(conn, batch, &totalImported, batchSize) + if err != nil { + fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err) + } + } + fmt.Printf("Processed page %d/%d\n", result.pageNum, totalPages) + } + + if len(trackBatch) > 0 { + err := insertBatch(conn, trackBatch, &totalImported, batchSize) + if err != nil { + fmt.Fprintf(os.Stderr, "Final batch insert failed: %v\n", err) + } + } + + fmt.Printf("%d tracks imported from LastFM for user %s\n", totalImported, username) + return nil +} + +func insertBatch(conn *pgx.Conn, tracks []LastFMTrack, totalImported *int, batchSize int) error { + tx, err := conn.Begin(context.Background()) + if err != nil { + return err + } + + var batchValues []string + var batchArgs []any + + for i, track := range tracks { + batchValues = append(batchValues, fmt.Sprintf( + "($%d, $%d, $%d, $%d, $%d)", + len( + batchArgs, + )+1, + len(batchArgs)+2, + len(batchArgs)+3, + len(batchArgs)+4, + len(batchArgs)+5, + )) + batchArgs = append(batchArgs, track.Timestamp, track.SongName, track.Artist, track.Album, 0) + + if len(batchValues) >= batchSize || i == len(tracks)-1 { + result, err := tx.Exec( + context.Background(), + `INSERT INTO history (timestamp, song_name, artist, album_name, ms_played) VALUES `+ + strings.Join(batchValues, ", ")+` ON CONFLICT DO NOTHING;`, + batchArgs..., + ) + if err != nil { + tx.Rollback(context.Background()) + return err + } + rowsAffected := result.RowsAffected() + if rowsAffected > 0 { + *totalImported += int(rowsAffected) + } + batchValues = batchValues[:0] + batchArgs = batchArgs[:0] + } + } + + if err := tx.Commit(context.Background()); err != nil { + return err + } + + return nil +} diff --git a/migrate/migrate.go b/migrate/migrate.go new file mode 100644 index 0000000..63a3faf --- /dev/null +++ b/migrate/migrate.go @@ -0,0 +1,55 @@ +package migrate + +import ( + "context" + "fmt" + "os" + + "github.com/jackc/pgx/v5" +) + +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 +} diff --git a/migrate/spotify.go b/migrate/spotify.go new file mode 100644 index 0000000..41260e1 --- /dev/null +++ b/migrate/spotify.go @@ -0,0 +1,332 @@ +package migrate + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jackc/pgx/v5" +) + +type SpotifyTrack struct { + Timestamp string `json:"ts"` + Platform string `json:"-"` + Played int `json:"ms_played"` + Country string `json:"-"` + IP string `json:"-"` + 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:"-"` + Episode string `json:"-"` + Show string `json:"-"` + EpisodeURI string `json:"-"` + Audiobook string `json:"-"` + AudiobookURI string `json:"-"` + AudiobookChapterURI string `json:"-"` + AudiobookChapter string `json:"-"` + ReasonStart string `json:"-"` + ReasonEnd string `json:"-"` + Shuffle bool `json:"-"` + Skipped bool `json:"-"` + Offline bool `json:"-"` + OfflineTimestamp int `json:"-"` + Incognito bool `json:"-"` +} + +func trackKey(t SpotifyTrack) string { + return fmt.Sprintf("%s|%d|%s|%s", t.Timestamp, t.Played, t.Artist, t.Name) +} + +func getExistingTracks(conn *pgx.Conn, tracks []SpotifyTrack) (map[string]bool, error) { + if len(tracks) == 0 { + return map[string]bool{}, nil + } + + var conditions []string + var args []any + + for i, t := range tracks { + base := i * 4 + conditions = append(conditions, + fmt.Sprintf("(timestamp=$%d AND ms_played=$%d AND artist=$%d AND song_name=$%d)", + base+1, base+2, base+3, base+4)) + args = append(args, t.Timestamp, t.Played, t.Artist, t.Name) + } + + query := fmt.Sprintf( + "SELECT timestamp, ms_played, artist, song_name FROM history WHERE %s", + strings.Join(conditions, " OR ")) + + rows, err := conn.Query(context.Background(), query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + existing := make(map[string]bool) + for rows.Next() { + var ts string + var played int + var artist, song string + if err := rows.Scan(&ts, &played, &artist, &song); err != nil { + continue + } + key := fmt.Sprintf("%s|%d|%s|%s", ts, played, artist, song) + existing[key] = true + } + + return existing, nil +} + +func JsonToDB(jsonFile string) error { + if !DbExists() { + err := CreateDB() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating muzi database: %v\n", err) + 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) + return err + } + var tracks []SpotifyTrack + err = json.Unmarshal(jsonData, &tracks) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot unmarshal %s: %v\n", jsonFile, err) + return err + } + + totalImported := 0 + batchSize := 1000 + + for batchStart := 0; batchStart < len(tracks); batchStart += batchSize { + batchEnd := batchStart + batchSize + if batchEnd > len(tracks) { + batchEnd = len(tracks) + } + + var validTracks []SpotifyTrack + for i := batchStart; i < batchEnd; i++ { + if tracks[i].Played >= 20000 { + validTracks = append(validTracks, tracks[i]) + } + } + + if len(validTracks) == 0 { + continue + } + + existing, err := getExistingTracks(conn, validTracks) + if err != nil { + fmt.Fprintf(os.Stderr, "Error checking existing tracks: %v\n", err) + continue + } + + var batchValues []string + var batchArgs []any + + for _, t := range validTracks { + key := trackKey(t) + if existing[key] { + continue + } + + batchValues = append(batchValues, fmt.Sprintf( + "($%d, $%d, $%d, $%d, $%d)", + len(batchArgs)+1, + len(batchArgs)+2, + len(batchArgs)+3, + len(batchArgs)+4, + len(batchArgs)+5, + )) + batchArgs = append(batchArgs, t.Timestamp, t.Name, t.Artist, t.Album, t.Played) + } + + if len(batchValues) == 0 { + continue + } + + _, err = conn.Exec( + context.Background(), + `INSERT INTO history (timestamp, song_name, artist, album_name, ms_played) VALUES `+ + strings.Join(batchValues, ", ")+ + ` ON CONFLICT DO NOTHING;`, + batchArgs..., + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Batch insert failed: %v\n", err) + } else { + totalImported += len(batchValues) + } + } + + fmt.Printf("%d tracks imported from %s\n", totalImported, jsonFile) + return nil +} + +func AddDirToDB(path string) error { + dirs, err := os.ReadDir(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", path, err) + return err + } + for _, dir := range dirs { + subPath := filepath.Join( + path, + dir.Name(), + "Spotify Extended Streaming History", + ) + entries, err := os.ReadDir(subPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error while reading path: %s: %v\n", subPath, err) + return err + } + for _, f := range entries { + jsonFileName := f.Name() + 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) + err = JsonToDB(jsonFilePath) + if err != nil { + fmt.Fprintf(os.Stderr, + "Error adding json data (%s) to muzi database: %v", jsonFilePath, err) + return err + } + } + } + return nil +} + +func ImportSpotify() error { + path := filepath.Join(".", "imports", "spotify", "zip") + targetBase := filepath.Join(".", "imports", "spotify", "extracted") + entries, err := os.ReadDir(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading path: %s: %v\n", path, err) + return err + } + for _, f := range entries { + _, err := zip.OpenReader(filepath.Join(path, f.Name())) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", + filepath.Join(path, f.Name()), err) + continue + } + fileName := f.Name() + fileFullPath := filepath.Join(path, fileName) + fileBaseName := fileName[:(strings.LastIndex(fileName, "."))] + targetDirFullPath := filepath.Join(targetBase, fileBaseName) + + err = Extract(fileFullPath, targetDirFullPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error extracting %s to %s: %v\n", + fileFullPath, targetDirFullPath, err) + return err + } + } + err = AddDirToDB(targetBase) + if err != nil { + fmt.Fprintf(os.Stderr, + "Error adding directory of data (%s) to muzi database: %v\n", + targetBase, err) + return err + } + return nil +} + +func Extract(path string, target string) error { + archive, err := zip.OpenReader(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening zip: %s: %v\n", path, err) + return 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), + ) { + err = fmt.Errorf("Invalid file path: %s", filePath) + fmt.Fprintf(os.Stderr, "%v\n", err) + return err + } + 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 { + fmt.Fprintf(os.Stderr, "Error making directory: %s: %v\n", + filepath.Dir(filePath), err) + return err + } + fileToExtract, err := os.OpenFile( + filePath, + os.O_WRONLY|os.O_CREATE|os.O_TRUNC, + f.Mode(), + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", filePath, err) + return err + } + extractedFile, err := f.Open() + if err != nil { + fmt.Fprintf(os.Stderr, "Error opening file: %s: %v\n", f.Name, err) + return err + } + if _, err := io.Copy(fileToExtract, extractedFile); err != nil { + fmt.Fprintf( + os.Stderr, + "Error while copying file: %s to: %s: %v\n", + fileToExtract.Name(), + extractedFile, + err, + ) + return err + } + fileToExtract.Close() + extractedFile.Close() + } + return nil +} diff --git a/static/assets/default.png b/static/assets/default.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd5d107127e9025c4baf26d75346b9ff3cfb7e1 GIT binary patch literal 7915 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 ZazUS=<=`Glg15=YzCEPf1y-jR{{^)>783vf literal 0 HcmV?d00001 diff --git a/templates/create_account.gohtml b/templates/create_account.gohtml index 2d7c748..1277be3 100644 --- a/templates/create_account.gohtml +++ b/templates/create_account.gohtml @@ -1,7 +1,7 @@ - + muzi | Create Account diff --git a/templates/history.gohtml b/templates/history.gohtml index a575777..b63218d 100644 --- a/templates/history.gohtml +++ b/templates/history.gohtml @@ -1,7 +1,7 @@ - + muzi | History diff --git a/templates/login.gohtml b/templates/login.gohtml index 087597d..7a85c98 100644 --- a/templates/login.gohtml +++ b/templates/login.gohtml @@ -1,7 +1,7 @@ - + muzi | Login @@ -14,6 +14,11 @@

+ {{if .ShowError}} + + {{end}} diff --git a/templates/profile.gohtml b/templates/profile.gohtml index 0aa35fa..4178237 100644 --- a/templates/profile.gohtml +++ b/templates/profile.gohtml @@ -1,12 +1,22 @@ - + muzi | {{.Username}}'s Profile - {{.Bio}} +
+ {{.Username}}'s avatar +
+

{{.Username}}

+

{{.Bio}}

+
+
+

101238 Listens

+

1298 Artists

+
+
diff --git a/web/web.go b/web/web.go index 5805bb2..1e0d3c1 100644 --- a/web/web.go +++ b/web/web.go @@ -7,7 +7,9 @@ import ( "net/http" "os" "strconv" - "muzi/importsongs" + + "muzi/migrate" + "golang.org/x/crypto/bcrypt" "github.com/go-chi/chi/v5" @@ -24,7 +26,6 @@ type PageData struct { Page int } - func Sub(a int, b int) int { return a - b } @@ -35,7 +36,12 @@ func Add(a int, b int) int { 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) + 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 @@ -54,7 +60,12 @@ func getTimes(conn *pgx.Conn, lim int, off int) []string { 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) + 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 @@ -73,7 +84,12 @@ func getTitles(conn *pgx.Conn, lim int, off int) []string { 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) + 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 @@ -118,7 +134,10 @@ func verifyPassword(hashedPassword string, enteredPassword []byte) bool { } func createAccount(w http.ResponseWriter, r *http.Request) { - conn, err := pgx.Connect(context.Background(), "postgres://postgres:postgres@localhost:5432/muzi") + 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 @@ -131,47 +150,62 @@ func createAccount(w http.ResponseWriter, r *http.Request) { username := r.FormValue("uname") hashedPassword := hashPassword([]byte(r.FormValue("pass"))) - if importsongs.TableExists("users", conn) == false { - _, err = conn.Exec( - context.Background(), - `CREATE TABLE users (username TEXT, password TEXT, pk SERIAL, PRIMARY KEY (pk));`, - ) - if err != nil { - fmt.Fprintf(os.Stderr, "Cannot create users table: %v\n", err) - panic(err) + if !migrate.TableExists("users", conn) { + _, err = conn.Exec( + context.Background(), + `CREATE TABLE users ( + username TEXT, + password TEXT, + bio TEXT, + pfp TEXT, + pk SERIAL, + PRIMARY KEY (pk) + );`, + ) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot create users table: %v\n", err) + panic(err) + } } - } _, err = conn.Exec( - context.Background(), `INSERT INTO users (username, password) VALUES ($1, $2);`, - username, - hashedPassword, - ) + context.Background(), + `INSERT INTO users (username, password, bio, pfp) VALUES ($1, $2, $3, $4);`, + username, + hashedPassword, + "This profile has no bio.", + "/files/assets/default.png", + ) 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 { - http.Redirect(w, r, "/profile/" + username, http.StatusSeeOther) + http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther) } } } -func createAccountHandler(w http.ResponseWriter, r *http.Request) { - tmp, err := template.New("create_account.gohtml").ParseFiles("./templates/create_account.gohtml") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - err = tmp.Execute(w, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return +func createAccountPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tmp, err := template.New("create_account.gohtml"). + ParseFiles("./templates/create_account.gohtml") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + err = tmp.Execute(w, nil) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } } func loginSubmit(w http.ResponseWriter, r *http.Request) { - conn, err := pgx.Connect(context.Background(), "postgres://postgres:postgres@localhost:5432/muzi") + 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 @@ -180,38 +214,52 @@ func loginSubmit(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { r.ParseForm() - + username := r.FormValue("uname") password := r.FormValue("pass") var storedPassword string - err := conn.QueryRow(context.Background(), "SELECT password FROM users WHERE username = $1;", username).Scan(&storedPassword) + err := conn.QueryRow(context.Background(), "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)) { - http.Redirect(w, r, "/profile/" + username, http.StatusSeeOther) + http.Redirect(w, r, "/profile/"+username, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/login?error=1", http.StatusSeeOther) } } } -func loginPage(w http.ResponseWriter, r *http.Request) { - tmp, err := template.New("login.gohtml").ParseFiles("./templates/login.gohtml") - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } +func loginPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + type data struct { + ShowError bool + } + d := data{ShowError: false} + if r.URL.Query().Get("error") != "" { + d.ShowError = true + } + tmp, err := template.New("login.gohtml").ParseFiles("./templates/login.gohtml") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - err = tmp.Execute(w, nil) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + err = tmp.Execute(w, d) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } } func historyPage(w http.ResponseWriter, r *http.Request) { - - conn, err := pgx.Connect(context.Background(), "postgres://postgres:postgres@localhost:5432/muzi") + 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 @@ -242,12 +290,14 @@ func historyPage(w http.ResponseWriter, r *http.Request) { Page: pageInt, } -funcMap := template.FuncMap{ - "Sub": Sub, - "Add": Add, -} + funcMap := template.FuncMap{ + "Sub": Sub, + "Add": Add, + } - tmp, err := template.New("history.gohtml").Funcs(funcMap).ParseFiles("./templates/history.gohtml") + tmp, err := template.New("history.gohtml"). + Funcs(funcMap). + ParseFiles("./templates/history.gohtml") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -260,29 +310,34 @@ funcMap := template.FuncMap{ } } -type Profile struct { - Username string - Bio string -} - -func Start() { - addr := ":1234" - 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.Get("/history", historyPage) - r.Get("/login", loginPage) - r.Get("/createaccount", createAccountHandler) - // TODO: clean this up - r.Get("/profile/{username}", func(w http.ResponseWriter, r *http.Request) { +func profilePageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") - - profileData := Profile { - Username: username, - Bio: "default", + + 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) + http.Error(w, err.Error(), http.StatusInternalServerError) + return } + defer conn.Close(context.Background()) + + var profileData Profile + + err = conn.QueryRow( + context.Background(), + "SELECT bio, pfp FROM users WHERE username = $1;", + username, + ).Scan(&profileData.Bio, &profileData.Pfp) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get profile for %s: %v\n", username, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + profileData.Username = username tmp, err := template.ParseFiles("./templates/profile.gohtml") if err != nil { @@ -290,7 +345,24 @@ func Start() { return } tmp.Execute(w, profileData) - }) + } +} + +type Profile struct { + Username string + Bio string + Pfp string +} + +func Start() { + addr := ":1234" + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Handle("/files/*", http.StripPrefix("/files", http.FileServer(http.Dir("./static")))) + r.Get("/history", historyPage) + r.Get("/login", loginPageHandler()) + r.Get("/createaccount", createAccountPageHandler()) + r.Get("/profile/{username}", profilePageHandler()) r.Post("/loginsubmit", loginSubmit) r.Post("/createaccountsubmit", createAccount) fmt.Printf("WebUI starting on %s\n", addr)