diff --git a/db/db.go b/db/db.go index 65023a8..5f4f92d 100644 --- a/db/db.go +++ b/db/db.go @@ -14,6 +14,9 @@ import ( var Pool *pgxpool.Pool func CreateAllTables() error { + if err := CreateExtensions(); err != nil { + return err + } if err := CreateHistoryTable(); err != nil { return err } @@ -23,7 +26,32 @@ func CreateAllTables() error { if err := CreateSessionsTable(); err != nil { return err } - return CreateSpotifyLastTrackTable() + if err := CreateSpotifyLastTrackTable(); err != nil { + return err + } + if err := CreateArtistsTable(); err != nil { + return err + } + if err := CreateAlbumsTable(); err != nil { + return err + } + if err := CreateSongsTable(); err != nil { + return err + } + if err := AddHistoryEntityColumns(); err != nil { + return err + } + return nil +} + +func CreateExtensions() error { + _, err := Pool.Exec(context.Background(), + "CREATE EXTENSION IF NOT EXISTS pg_trgm;") + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating pg_trgm extension: %v\n", err) + return err + } + return nil } func GetDbUrl(dbName bool) string { @@ -153,3 +181,80 @@ func CreateSpotifyLastTrackTable() error { } return nil } + +func CreateArtistsTable() error { + _, err := Pool.Exec(context.Background(), + `CREATE TABLE IF NOT EXISTS artists ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE, + name TEXT NOT NULL, + image_url TEXT, + bio TEXT, + spotify_id TEXT, + musicbrainz_id TEXT, + UNIQUE (user_id, name) + ); + CREATE INDEX IF NOT EXISTS idx_artists_user_name ON artists(user_id, name); + CREATE INDEX IF NOT EXISTS idx_artists_user_name_trgm ON artists USING gin(name gin_trgm_ops);`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating artists table: %v\n", err) + return err + } + return nil +} + +func CreateAlbumsTable() error { + _, err := Pool.Exec(context.Background(), + `CREATE TABLE IF NOT EXISTS albums ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE, + title TEXT NOT NULL, + artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL, + cover_url TEXT, + spotify_id TEXT, + musicbrainz_id TEXT, + UNIQUE (user_id, title, artist_id) + ); + CREATE INDEX IF NOT EXISTS idx_albums_user_title ON albums(user_id, title); + CREATE INDEX IF NOT EXISTS idx_albums_user_title_trgm ON albums USING gin(title gin_trgm_ops);`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating albums table: %v\n", err) + return err + } + return nil +} + +func CreateSongsTable() error { + _, err := Pool.Exec(context.Background(), + `CREATE TABLE IF NOT EXISTS songs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(pk) ON DELETE CASCADE, + title TEXT NOT NULL, + artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL, + album_id INTEGER REFERENCES albums(id) ON DELETE SET NULL, + duration_ms INTEGER, + spotify_id TEXT, + musicbrainz_id TEXT, + UNIQUE (user_id, title, artist_id) + ); + CREATE INDEX IF NOT EXISTS idx_songs_user_title ON songs(user_id, title); + CREATE INDEX IF NOT EXISTS idx_songs_user_title_trgm ON songs USING gin(title gin_trgm_ops);`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating songs table: %v\n", err) + return err + } + return nil +} + +func AddHistoryEntityColumns() error { + _, err := Pool.Exec(context.Background(), + `ALTER TABLE history ADD COLUMN IF NOT EXISTS artist_id INTEGER REFERENCES artists(id) ON DELETE SET NULL; + ALTER TABLE history ADD COLUMN IF NOT EXISTS song_id INTEGER REFERENCES songs(id) ON DELETE SET NULL; + CREATE INDEX IF NOT EXISTS idx_history_artist_id ON history(artist_id); + CREATE INDEX IF NOT EXISTS idx_history_song_id ON history(song_id);`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error adding history entity columns: %v\n", err) + return err + } + return nil +} diff --git a/db/entities.go b/db/entities.go new file mode 100644 index 0000000..f3dfd4e --- /dev/null +++ b/db/entities.go @@ -0,0 +1,621 @@ +package db + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgtype" +) + +type Artist struct { + Id int + UserId int + Name string + ImageUrl string + Bio string + SpotifyId string + MusicbrainzId string +} + +type Album struct { + Id int + UserId int + Title string + ArtistId int + CoverUrl string + SpotifyId string + MusicbrainzId string +} + +type Song struct { + Id int + UserId int + Title string + ArtistId int + AlbumId int + DurationMs int + SpotifyId string + MusicbrainzId string +} + +func GetOrCreateArtist(userId int, name string) (int, bool, error) { + if name == "" { + return 0, false, nil + } + + var id int + err := Pool.QueryRow(context.Background(), + "SELECT id FROM artists WHERE user_id = $1 AND name = $2", + userId, name).Scan(&id) + if err == nil { + return id, false, nil + } + + err = Pool.QueryRow(context.Background(), + `INSERT INTO artists (user_id, name) VALUES ($1, $2) + ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name + RETURNING id`, + userId, name).Scan(&id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating artist: %v\n", err) + return 0, false, err + } + return id, true, nil +} + +func GetArtistById(id int) (Artist, error) { + var artist Artist + var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + err := Pool.QueryRow(context.Background(), + "SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE id = $1", + id).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return Artist{}, err + } + if imageUrlPg.Status == pgtype.Present { + artist.ImageUrl = imageUrlPg.String + } + if bioPg.Status == pgtype.Present { + artist.Bio = bioPg.String + } + if spotifyIdPg.Status == pgtype.Present { + artist.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + artist.MusicbrainzId = musicbrainzIdPg.String + } + return artist, nil +} + +func GetArtistByName(userId int, name string) (Artist, error) { + var artist Artist + var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + err := Pool.QueryRow(context.Background(), + "SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id FROM artists WHERE user_id = $1 AND name = $2", + userId, name).Scan(&artist.Id, &artist.UserId, &artist.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return Artist{}, err + } + if imageUrlPg.Status == pgtype.Present { + artist.ImageUrl = imageUrlPg.String + } + if bioPg.Status == pgtype.Present { + artist.Bio = bioPg.String + } + if spotifyIdPg.Status == pgtype.Present { + artist.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + artist.MusicbrainzId = musicbrainzIdPg.String + } + return artist, nil +} + +func UpdateArtist(id int, name, imageUrl, bio, spotifyId, musicbrainzId string) error { + _, err := Pool.Exec(context.Background(), + `UPDATE artists SET name = $1, image_url = $2, bio = $3, spotify_id = $4, musicbrainz_id = $5 WHERE id = $6`, + name, imageUrl, bio, spotifyId, musicbrainzId, id) + return err +} + +func SearchArtists(userId int, query string) ([]Artist, error) { + likePattern := "%" + query + "%" + rows, err := Pool.Query(context.Background(), + `SELECT id, user_id, name, image_url, bio, spotify_id, musicbrainz_id + FROM artists WHERE user_id = $1 AND (similarity(name, $2) > 0.1 OR LOWER(name) LIKE LOWER($3)) + ORDER BY similarity(name, $2) DESC LIMIT 20`, + userId, query, likePattern) + if err != nil { + return nil, err + } + defer rows.Close() + + var artists []Artist + for rows.Next() { + var a Artist + var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return nil, err + } + if imageUrlPg.Status == pgtype.Present { + a.ImageUrl = imageUrlPg.String + } + if bioPg.Status == pgtype.Present { + a.Bio = bioPg.String + } + if spotifyIdPg.Status == pgtype.Present { + a.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + a.MusicbrainzId = musicbrainzIdPg.String + } + artists = append(artists, a) + } + return artists, nil +} + +func GetOrCreateAlbum(userId int, title string, artistId int) (int, bool, error) { + if title == "" { + return 0, false, nil + } + + var id int + err := Pool.QueryRow(context.Background(), + "SELECT id FROM albums WHERE user_id = $1 AND title = $2 AND (artist_id = $3 OR (artist_id IS NULL AND $3 IS NULL))", + userId, title, artistId).Scan(&id) + if err == nil { + return id, false, nil + } + + err = Pool.QueryRow(context.Background(), + `INSERT INTO albums (user_id, title, artist_id) VALUES ($1, $2, $3) + ON CONFLICT (user_id, title, artist_id) DO UPDATE SET title = EXCLUDED.title + RETURNING id`, + userId, title, artistId).Scan(&id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating album: %v\n", err) + return 0, false, err + } + return id, true, nil +} + +func GetAlbumById(id int) (Album, error) { + var album Album + err := Pool.QueryRow(context.Background(), + "SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id FROM albums WHERE id = $1", + id).Scan(&album.Id, &album.UserId, &album.Title, &album.ArtistId, &album.CoverUrl, + &album.SpotifyId, &album.MusicbrainzId) + if err != nil { + return Album{}, err + } + return album, nil +} + +func GetAlbumByName(userId int, title string, artistId int) (Album, error) { + var album Album + var artistIdVal int + var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + + var query string + var args []interface{} + if artistId > 0 { + query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id + FROM albums WHERE user_id = $1 AND title = $2 AND artist_id = $3` + args = []interface{}{userId, title, artistId} + } else { + query = `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id + FROM albums WHERE user_id = $1 AND title = $2` + args = []interface{}{userId, title} + } + + err := Pool.QueryRow(context.Background(), query, args...).Scan( + &album.Id, &album.UserId, &album.Title, &artistIdVal, &coverUrlPg, + &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return Album{}, err + } + album.ArtistId = artistIdVal + if coverUrlPg.Status == pgtype.Present { + album.CoverUrl = coverUrlPg.String + } + if spotifyIdPg.Status == pgtype.Present { + album.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + album.MusicbrainzId = musicbrainzIdPg.String + } + return album, nil +} + +func UpdateAlbum(id int, title, coverUrl, spotifyId, musicbrainzId string) error { + _, err := Pool.Exec(context.Background(), + `UPDATE albums SET title = $1, cover_url = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`, + title, coverUrl, spotifyId, musicbrainzId, id) + return err +} + +func SearchAlbums(userId int, query string) ([]Album, error) { + likePattern := "%" + query + "%" + rows, err := Pool.Query(context.Background(), + `SELECT id, user_id, title, artist_id, cover_url, spotify_id, musicbrainz_id + FROM albums WHERE user_id = $1 AND (similarity(title, $2) > 0.1 OR LOWER(title) LIKE LOWER($3)) + ORDER BY similarity(title, $2) DESC LIMIT 20`, + userId, query, likePattern) + if err != nil { + return nil, err + } + defer rows.Close() + + var albums []Album + for rows.Next() { + var a Album + var artistIdVal int + var coverUrlPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + err := rows.Scan(&a.Id, &a.UserId, &a.Title, &artistIdVal, &coverUrlPg, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return nil, err + } + a.ArtistId = artistIdVal + if coverUrlPg.Status == pgtype.Present { + a.CoverUrl = coverUrlPg.String + } + if spotifyIdPg.Status == pgtype.Present { + a.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + a.MusicbrainzId = musicbrainzIdPg.String + } + albums = append(albums, a) + } + return albums, nil +} + +func GetOrCreateSong(userId int, title string, artistId int, albumId int) (int, bool, error) { + if title == "" { + return 0, false, nil + } + + var id int + err := Pool.QueryRow(context.Background(), + "SELECT id FROM songs WHERE user_id = $1 AND title = $2 AND (artist_id = $3 OR (artist_id IS NULL AND $3 IS NULL))", + userId, title, artistId).Scan(&id) + if err == nil { + return id, false, nil + } + + err = Pool.QueryRow(context.Background(), + `INSERT INTO songs (user_id, title, artist_id, album_id) VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, title, artist_id) DO UPDATE SET title = EXCLUDED.title + RETURNING id`, + userId, title, artistId, albumId).Scan(&id) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating song: %v\n", err) + return 0, false, err + } + return id, true, nil +} + +func GetSongById(id int) (Song, error) { + var song Song + err := Pool.QueryRow(context.Background(), + "SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id FROM songs WHERE id = $1", + id).Scan(&song.Id, &song.UserId, &song.Title, &song.ArtistId, &song.AlbumId, + &song.DurationMs, &song.SpotifyId, &song.MusicbrainzId) + if err != nil { + return Song{}, err + } + return song, nil +} + +func GetSongByName(userId int, title string, artistId int) (Song, error) { + var song Song + var artistIdVal, albumIdVal int + var durationMs *int + var spotifyIdPg, musicbrainzIdPg pgtype.Text + + var query string + var args []interface{} + if artistId > 0 { + query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id + FROM songs WHERE user_id = $1 AND title = $2 AND artist_id = $3` + args = []interface{}{userId, title, artistId} + } else { + query = `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id + FROM songs WHERE user_id = $1 AND title = $2` + args = []interface{}{userId, title} + } + + err := Pool.QueryRow(context.Background(), query, args...).Scan( + &song.Id, &song.UserId, &song.Title, &artistIdVal, &albumIdVal, + &durationMs, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return Song{}, err + } + song.ArtistId = artistIdVal + song.AlbumId = albumIdVal + if durationMs != nil { + song.DurationMs = *durationMs + } + if spotifyIdPg.Status == pgtype.Present { + song.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + song.MusicbrainzId = musicbrainzIdPg.String + } + return song, nil +} + +func UpdateSong(id int, title string, durationMs int, spotifyId, musicbrainzId string) error { + _, err := Pool.Exec(context.Background(), + `UPDATE songs SET title = $1, duration_ms = $2, spotify_id = $3, musicbrainz_id = $4 WHERE id = $5`, + title, durationMs, spotifyId, musicbrainzId, id) + return err +} + +func SearchSongs(userId int, query string) ([]Song, error) { + likePattern := "%" + query + "%" + rows, err := Pool.Query(context.Background(), + `SELECT id, user_id, title, artist_id, album_id, duration_ms, spotify_id, musicbrainz_id + FROM songs WHERE user_id = $1 AND (similarity(title, $2) > 0.1 OR LOWER(title) LIKE LOWER($3)) + ORDER BY similarity(title, $2) DESC LIMIT 20`, + userId, query, likePattern) + if err != nil { + return nil, err + } + defer rows.Close() + + var songs []Song + for rows.Next() { + var s Song + var artistIdVal, albumIdVal, durationMsVal int + var spotifyIdPg, musicbrainzIdPg pgtype.Text + err := rows.Scan(&s.Id, &s.UserId, &s.Title, &artistIdVal, &albumIdVal, &durationMsVal, &spotifyIdPg, &musicbrainzIdPg) + if err != nil { + return nil, err + } + s.ArtistId = artistIdVal + s.AlbumId = albumIdVal + s.DurationMs = durationMsVal + if spotifyIdPg.Status == pgtype.Present { + s.SpotifyId = spotifyIdPg.String + } + if musicbrainzIdPg.Status == pgtype.Present { + s.MusicbrainzId = musicbrainzIdPg.String + } + songs = append(songs, s) + } + return songs, nil +} + +func GetArtistStats(userId, artistId int) (int, error) { + var count int + err := Pool.QueryRow(context.Background(), + "SELECT COUNT(*) FROM history WHERE user_id = $1 AND artist_id = $2", + userId, artistId).Scan(&count) + return count, err +} + +func GetSongStats(userId, songId int) (int, error) { + var count int + err := Pool.QueryRow(context.Background(), + "SELECT COUNT(*) FROM history WHERE user_id = $1 AND song_id = $2", + userId, songId).Scan(&count) + return count, err +} + +func MergeArtists(userId int, fromArtistId, toArtistId int) error { + _, err := Pool.Exec(context.Background(), + `UPDATE history SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`, + toArtistId, userId, fromArtistId) + if err != nil { + return err + } + _, err = Pool.Exec(context.Background(), + `UPDATE songs SET artist_id = $1 WHERE user_id = $2 AND artist_id = $3`, + toArtistId, userId, fromArtistId) + if err != nil { + return err + } + _, err = Pool.Exec(context.Background(), + `DELETE FROM artists WHERE id = $1 AND user_id = $2`, + fromArtistId, userId) + return err +} + +func GetHistoryForArtist(userId, artistId int, limit, offset int) ([]ScrobbleEntry, error) { + rows, err := Pool.Query(context.Background(), + `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, + (SELECT name FROM artists WHERE id = h.artist_id) as artist_name + FROM history h WHERE h.user_id = $1 AND h.artist_id = $2 + ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, + userId, artistId, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ScrobbleEntry + for rows.Next() { + var e ScrobbleEntry + err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) + if err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, nil +} + +func GetHistoryForSong(userId, songId int, limit, offset int) ([]ScrobbleEntry, error) { + rows, err := Pool.Query(context.Background(), + `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, + (SELECT name FROM artists WHERE id = h.artist_id) as artist_name + FROM history h WHERE h.user_id = $1 AND h.song_id = $2 + ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, + userId, songId, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ScrobbleEntry + for rows.Next() { + var e ScrobbleEntry + err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) + if err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, nil +} + +type ScrobbleEntry struct { + Timestamp time.Time + SongName string + ArtistName string + AlbumName string + MsPlayed int + Platform string +} + +func MigrateHistoryEntities() error { + rows, err := Pool.Query(context.Background(), + `SELECT DISTINCT h.user_id, h.artist, h.song_name, h.album_name + FROM history h + WHERE h.artist_id IS NULL OR h.song_id IS NULL`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching history for migration: %v\n", err) + return err + } + defer rows.Close() + + type UniqueEntity struct { + UserId int + Artist string + SongName string + Album string + } + + var entities []UniqueEntity + seen := make(map[string]bool) + for rows.Next() { + var r UniqueEntity + if err := rows.Scan(&r.UserId, &r.Artist, &r.SongName, &r.Album); err != nil { + continue + } + key := fmt.Sprintf("%d-%s-%s-%s", r.UserId, r.Artist, r.SongName, r.Album) + if !seen[key] { + seen[key] = true + entities = append(entities, r) + } + } + + artistIds := make(map[string]int) + albumIds := make(map[string]int) + + for _, e := range entities { + if e.Artist == "" { + continue + } + key := fmt.Sprintf("%d-%s", e.UserId, e.Artist) + if _, exists := artistIds[key]; !exists { + id, _, err := GetOrCreateArtist(e.UserId, e.Artist) + if err != nil { + continue + } + artistIds[key] = id + } + } + + for _, e := range entities { + if e.Album == "" || e.Artist == "" { + continue + } + artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist) + artistId, ok := artistIds[artistKey] + if !ok { + continue + } + albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId) + if _, exists := albumIds[albumKey]; !exists { + id, _, err := GetOrCreateAlbum(e.UserId, e.Album, artistId) + if err != nil { + continue + } + albumIds[albumKey] = id + } + } + + for _, e := range entities { + if e.SongName == "" || e.Artist == "" { + continue + } + artistKey := fmt.Sprintf("%d-%s", e.UserId, e.Artist) + artistId, ok := artistIds[artistKey] + if !ok { + continue + } + + var albumId int + if e.Album != "" { + albumKey := fmt.Sprintf("%d-%s-%d", e.UserId, e.Album, artistId) + albumId = albumIds[albumKey] + } + + songId, _, err := GetOrCreateSong(e.UserId, e.SongName, artistId, albumId) + if err != nil { + continue + } + + _, err = Pool.Exec(context.Background(), + `UPDATE history SET artist_id = $1, song_id = $2 + WHERE user_id = $3 AND artist = $4 AND song_name = $5 + AND (artist_id IS NULL OR song_id IS NULL)`, + artistId, songId, e.UserId, e.Artist, e.SongName) + if err != nil { + fmt.Fprintf(os.Stderr, "Error updating history entity IDs: %v\n", err) + } + } + + return nil +} + +func GetAlbumStats(userId, albumId int) (int, error) { + var count int + err := Pool.QueryRow(context.Background(), + `SELECT COUNT(*) FROM history h + JOIN songs s ON h.song_id = s.id + WHERE h.user_id = $1 AND s.album_id = $2`, + userId, albumId).Scan(&count) + return count, err +} + +func GetHistoryForAlbum(userId, albumId int, limit, offset int) ([]ScrobbleEntry, error) { + rows, err := Pool.Query(context.Background(), + `SELECT h.timestamp, h.song_name, h.album_name, h.ms_played, h.platform, + (SELECT name FROM artists WHERE id = h.artist_id) as artist_name + FROM history h + JOIN songs s ON h.song_id = s.id + WHERE h.user_id = $1 AND s.album_id = $2 + ORDER BY h.timestamp DESC LIMIT $3 OFFSET $4`, + userId, albumId, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var entries []ScrobbleEntry + for rows.Next() { + var e ScrobbleEntry + err := rows.Scan(&e.Timestamp, &e.SongName, &e.AlbumName, &e.MsPlayed, &e.Platform, &e.ArtistName) + if err != nil { + return nil, err + } + entries = append(entries, e) + } + return entries, nil +} diff --git a/scrobble/scrobble.go b/scrobble/scrobble.go index 7b45e8c..85c06b0 100644 --- a/scrobble/scrobble.go +++ b/scrobble/scrobble.go @@ -118,12 +118,33 @@ func SaveScrobble(scrobble Scrobble) error { return fmt.Errorf("duplicate scrobble") } + artistId, _, err := db.GetOrCreateArtist(scrobble.UserId, scrobble.Artist) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting/creating artist: %v\n", err) + return err + } + + var albumId int + if scrobble.Album != "" { + albumId, _, err = db.GetOrCreateAlbum(scrobble.UserId, scrobble.Album, artistId) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting/creating album: %v\n", err) + return err + } + } + + songId, _, err := db.GetOrCreateSong(scrobble.UserId, scrobble.SongName, artistId, albumId) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting/creating song: %v\n", err) + return err + } + _, 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) + `INSERT INTO history (user_id, timestamp, song_name, artist, album_name, ms_played, platform, artist_id, song_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (user_id, song_name, artist, timestamp) DO NOTHING`, scrobble.UserId, scrobble.Timestamp, scrobble.SongName, scrobble.Artist, - scrobble.Album, scrobble.MsPlayed, scrobble.Platform) + scrobble.Album, scrobble.MsPlayed, scrobble.Platform, artistId, songId) if err != nil { fmt.Fprintf(os.Stderr, "Error saving scrobble: %v\n", err) return err diff --git a/static/assets/pfps/default_artist.png b/static/assets/pfps/default_artist.png new file mode 100644 index 0000000..0fd5d10 Binary files /dev/null and b/static/assets/pfps/default_artist.png differ diff --git a/static/menu.js b/static/menu.js index 79aec08..4f55800 100644 --- a/static/menu.js +++ b/static/menu.js @@ -29,4 +29,67 @@ document.addEventListener('DOMContentLoaded', function() { closeMenu(); } }); + + // Global Search + const searchInput = document.getElementById('globalSearch'); + const searchResults = document.getElementById('searchResults'); + let searchTimeout; + + if (searchInput) { + searchInput.addEventListener('input', function(e) { + const query = e.target.value.trim(); + + clearTimeout(searchTimeout); + + if (query.length < 2) { + searchResults.classList.remove('active'); + searchResults.innerHTML = ''; + return; + } + + searchTimeout = setTimeout(function() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/search?q=' + encodeURIComponent(query), true); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + var results = JSON.parse(xhr.responseText); + if (results.length === 0) { + searchResults.innerHTML = '
No results
'; + } else { + var html = ''; + for (var i = 0; i < results.length; i++) { + var r = results[i]; + html += '' + + '
' + + '' + r.name + '' + + '' + r.type + '' + + '
' + + '' + r.count + '' + + '
'; + } + searchResults.innerHTML = html; + } + searchResults.classList.add('active'); + } + } + }; + xhr.send(); + }, 300); + }); + + // Close search on escape + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + searchResults.classList.remove('active'); + } + }); + + // Close search when clicking outside + document.addEventListener('click', function(e) { + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { + searchResults.classList.remove('active'); + } + }); + } }); diff --git a/static/style.css b/static/style.css index f7997c2..8b4ff7e 100644 --- a/static/style.css +++ b/static/style.css @@ -112,6 +112,93 @@ filter: invert(1) brightness(1.5); } + /* Global Search */ + .search-container { + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 1001; + width: 300px; + background: #222; + padding: 10px; + border-radius: 30px; + } + + .search-container input { + width: 100%; + padding: 10px 15px; + border: 1px solid #444; + border-radius: 25px; + background: #333; + color: #AFA; + font-size: 14px; + outline: none; + box-sizing: border-box; + } + + .search-container input:focus { + border-color: #AFA; + background: #444; + } + + .search-results { + display: none; + position: absolute; + top: 100%; + left: 10px; + right: 10px; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 8px; + margin-top: 5px; + max-height: 300px; + overflow-y: auto; + z-index: 1002; + } + + .search-results.active { + display: block; + } + + .search-result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 15px; + cursor: pointer; + border-bottom: 1px solid #333; + text-decoration: none; + } + + .search-result-item:last-child { + border-bottom: none; + } + + .search-result-item:hover { + background: #333; + } + + .search-result-info { + display: flex; + flex-direction: column; + } + + .search-result-name { + color: #FFF; + font-size: 14px; + } + + .search-result-type { + color: #888; + font-size: 12px; + } + + .search-result-count { + color: #AFA; + font-size: 12px; + } + /* Menu Overlay */ .menu-overlay { position: fixed; @@ -124,11 +211,13 @@ opacity: 0; visibility: hidden; transition: opacity 0.3s ease, visibility 0.3s ease; + pointer-events: none; } .menu-overlay.active { opacity: 1; visibility: visible; + pointer-events: auto; } .page_buttons { @@ -227,6 +316,14 @@ tr:nth-child(even) { background-color: #111; } + a { + color: #AFA; + text-decoration: none; + } + a:hover { + color: #FFF; + text-decoration: underline; + } } .import-section { diff --git a/templates/album.gohtml b/templates/album.gohtml new file mode 100644 index 0000000..35da523 --- /dev/null +++ b/templates/album.gohtml @@ -0,0 +1,58 @@ +{{define "album"}} +
+ {{if .Album.CoverUrl}} + {{.Album.Title}}'s cover + {{else}} + {{.Album.Title}}'s cover + {{end}} +
+

{{.Album.Title}}

+ {{if .Artist.Name}} +

{{.Artist.Name}}

+ {{end}} +
+
+
+
+

{{formatInt .ListenCount}}

Listens

+

+
+ {{if eq .LoggedInUsername .Username}} +
+

Edit Album

+
+ + + + + +
+
+ {{end}} +
+

Scrobbles

+ + + + + + + + {{$username := .Username}} + {{range .Times}} + + + + + + + {{end}} +
ArtistTitleAlbumTimestamp
{{.ArtistName}}{{.SongName}}{{.AlbumName}}{{formatTimestamp .Timestamp}}
+
+
+ {{if gt .Page 1 }} + Prev Page + {{end}} + Next Page +
+{{end}} diff --git a/templates/artist.gohtml b/templates/artist.gohtml new file mode 100644 index 0000000..f6866af --- /dev/null +++ b/templates/artist.gohtml @@ -0,0 +1,57 @@ +{{define "artist"}} +
+ {{if .Artist.ImageUrl}} + {{.Artist.Name}}'s image + {{else}} + {{.Artist.Name}}'s image + {{end}} +
+

{{.Artist.Name}}

+

{{.Artist.Bio}}

+
+
+
+
+

{{formatInt .ListenCount}}

Listens

+

+
+ {{if eq .LoggedInUsername .Username}} +
+

Edit Artist

+
+ + + + + + +
+
+ {{end}} +
+

Scrobbles

+ + + + + + + + {{$username := .Username}} + {{range .Times}} + + + + + + + {{end}} +
ArtistTitleAlbumTimestamp
{{.ArtistName}}{{.SongName}}{{.AlbumName}}{{formatTimestamp .Timestamp}}
+
+
+ {{if gt .Page 1 }} + Prev Page + {{end}} + Next Page +
+{{end}} diff --git a/templates/base.gohtml b/templates/base.gohtml index 95da9c8..4bc10b0 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -13,6 +13,12 @@ + + +
+ +
+
@@ -44,6 +50,9 @@ {{ if eq .TemplateName "profile"}}{{block "profile" .}}{{end}}{{end}} {{ if eq .TemplateName "settings"}}{{block "settings" .}}{{end}}{{end}} + {{ if eq .TemplateName "artist"}}{{block "artist" .}}{{end}}{{end}} + {{ if eq .TemplateName "song"}}{{block "song" .}}{{end}}{{end}} + {{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}} diff --git a/templates/profile.gohtml b/templates/profile.gohtml index 96f09a5..e29ba4c 100644 --- a/templates/profile.gohtml +++ b/templates/profile.gohtml @@ -7,10 +7,10 @@
-
-

{{formatInt .ScrobbleCount}}

Listens

-

{{formatInt .ArtistCount}}

Artists

-

+
+

{{formatInt .ScrobbleCount}}

Listens

+

{{formatInt .ArtistCount}}

Artists

+

Listening History

@@ -29,10 +29,11 @@ {{end}} {{$artists := .Artists}} {{$times := .Times}} + {{$username := .Username}} {{range $index, $title := .Titles}} - {{index $artists $index}} - {{$title}} + {{index $artists $index}} + {{$title}} {{formatTimestamp (index $times $index)}} {{end}} diff --git a/templates/song.gohtml b/templates/song.gohtml new file mode 100644 index 0000000..3bb29d8 --- /dev/null +++ b/templates/song.gohtml @@ -0,0 +1,55 @@ +{{define "song"}} +
+
+

{{.Song.Title}}

+ {{if .Artist.Name}} +

{{.Artist.Name}}

+ {{end}} + {{if .Album.Title}} +

{{.Album.Title}}

+ {{end}} +
+
+
+
+

{{formatInt .ListenCount}}

Listens

+

+
+ {{if eq .LoggedInUsername .Username}} +
+

Edit Song

+
+ + + + +
+
+ {{end}} +
+

Scrobbles

+ + + + + + + + {{$username := .Username}} + {{range .Times}} + + + + + + + {{end}} +
ArtistTitleAlbumTimestamp
{{.ArtistName}}{{.SongName}}{{.AlbumName}}{{formatTimestamp .Timestamp}}
+
+
+ {{if gt .Page 1 }} + Prev Page + {{end}} + Next Page +
+{{end}} diff --git a/web/entity.go b/web/entity.go new file mode 100644 index 0000000..600c475 --- /dev/null +++ b/web/entity.go @@ -0,0 +1,445 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + + "muzi/db" + + "github.com/go-chi/chi/v5" +) + +type ArtistData struct { + Username string + Artist db.Artist + ListenCount int + Songs []string + Titles []string + Times []db.ScrobbleEntry + Page int + Title string + LoggedInUsername string + TemplateName string +} + +type SongData struct { + Username string + Song db.Song + Artist db.Artist + Album db.Album + ListenCount int + Times []db.ScrobbleEntry + Page int + Title string + LoggedInUsername string + TemplateName string +} + +type AlbumData struct { + Username string + Album db.Album + Artist db.Artist + ListenCount int + Times []db.ScrobbleEntry + Page int + Title string + LoggedInUsername string + TemplateName string +} + +func artistPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + artistName, err := url.QueryUnescape(chi.URLParam(r, "artist")) + if err != nil { + http.Error(w, "Invalid artist name", http.StatusBadRequest) + 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 + } + + artist, err := db.GetArtistByName(userId, artistName) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot find artist %s: %v\n", artistName, err) + http.Error(w, "Artist 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 { + pageInt = 1 + } + } + + lim := 15 + off := (pageInt - 1) * lim + + listenCount, err := db.GetArtistStats(userId, artist.Id) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get artist stats: %v\n", err) + } + + entries, err := db.GetHistoryForArtist(userId, artist.Id, lim, off) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get history for artist: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + artistData := ArtistData{ + Username: username, + Artist: artist, + ListenCount: listenCount, + Times: entries, + Page: pageInt, + Title: artistName + " - " + username, + LoggedInUsername: getLoggedInUsername(r), + TemplateName: "artist", + } + + err = templates.ExecuteTemplate(w, "base", artistData) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func songPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + songTitle, err := url.QueryUnescape(chi.URLParam(r, "song")) + if err != nil { + http.Error(w, "Invalid song title", http.StatusBadRequest) + 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 + } + + song, err := db.GetSongByName(userId, songTitle, 0) + if err != nil { + songs, searchErr := db.SearchSongs(userId, songTitle) + if searchErr == nil && len(songs) > 0 { + song = songs[0] + } else { + fmt.Fprintf(os.Stderr, "Cannot find song %s: %v\n", songTitle, err) + http.Error(w, "Song not found", http.StatusNotFound) + return + } + } + + artist, _ := db.GetArtistById(song.ArtistId) + var album db.Album + if song.AlbumId > 0 { + album, _ = db.GetAlbumById(song.AlbumId) + } + + pageStr := r.URL.Query().Get("page") + var pageInt int + if pageStr == "" { + pageInt = 1 + } else { + pageInt, err = strconv.Atoi(pageStr) + if err != nil { + pageInt = 1 + } + } + + lim := 15 + off := (pageInt - 1) * lim + + listenCount, err := db.GetSongStats(userId, song.Id) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get song stats: %v\n", err) + } + + entries, err := db.GetHistoryForSong(userId, song.Id, lim, off) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get history for song: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + songData := SongData{ + Username: username, + Song: song, + Artist: artist, + Album: album, + ListenCount: listenCount, + Times: entries, + Page: pageInt, + Title: songTitle + " - " + username, + LoggedInUsername: getLoggedInUsername(r), + TemplateName: "song", + } + + err = templates.ExecuteTemplate(w, "base", songData) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func editArtistHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := getLoggedInUsername(r) + if username == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + artistIdStr := chi.URLParam(r, "id") + artistId, err := strconv.Atoi(artistIdStr) + if err != nil { + http.Error(w, "Invalid artist ID", http.StatusBadRequest) + return + } + + r.ParseForm() + name := r.Form.Get("name") + imageUrl := r.Form.Get("image_url") + bio := r.Form.Get("bio") + spotifyId := r.Form.Get("spotify_id") + musicbrainzId := r.Form.Get("musicbrainz_id") + + err = db.UpdateArtist(artistId, name, imageUrl, bio, spotifyId, musicbrainzId) + if err != nil { + fmt.Fprintf(os.Stderr, "Error updating artist: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/artist/"+url.QueryEscape(name), http.StatusSeeOther) + } +} + +func editSongHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := getLoggedInUsername(r) + if username == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + r.ParseForm() + title := r.Form.Get("title") + spotifyId := r.Form.Get("spotify_id") + musicbrainzId := r.Form.Get("musicbrainz_id") + + songIdStr := chi.URLParam(r, "id") + songId, err := strconv.Atoi(songIdStr) + if err != nil { + http.Error(w, "Invalid song ID", http.StatusBadRequest) + return + } + + err = db.UpdateSong(songId, title, 0, spotifyId, musicbrainzId) + if err != nil { + fmt.Fprintf(os.Stderr, "Error updating song: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/song/"+url.QueryEscape(title)+"?username="+username, http.StatusSeeOther) + } +} + +func albumPageHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := chi.URLParam(r, "username") + albumTitle, err := url.QueryUnescape(chi.URLParam(r, "album")) + if err != nil { + http.Error(w, "Invalid album title", http.StatusBadRequest) + 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 + } + + album, err := db.GetAlbumByName(userId, albumTitle, 0) + if err != nil { + albums, searchErr := db.SearchAlbums(userId, albumTitle) + if searchErr == nil && len(albums) > 0 { + album = albums[0] + } else { + fmt.Fprintf(os.Stderr, "Cannot find album %s: %v\n", albumTitle, err) + http.Error(w, "Album not found", http.StatusNotFound) + return + } + } + + artist, _ := db.GetArtistById(album.ArtistId) + + pageStr := r.URL.Query().Get("page") + var pageInt int + if pageStr == "" { + pageInt = 1 + } else { + pageInt, err = strconv.Atoi(pageStr) + if err != nil { + pageInt = 1 + } + } + + lim := 15 + off := (pageInt - 1) * lim + + listenCount, err := db.GetAlbumStats(userId, album.Id) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get album stats: %v\n", err) + } + + entries, err := db.GetHistoryForAlbum(userId, album.Id, lim, off) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get history for album: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + albumData := AlbumData{ + Username: username, + Album: album, + Artist: artist, + ListenCount: listenCount, + Times: entries, + Page: pageInt, + Title: albumTitle + " - " + username, + LoggedInUsername: getLoggedInUsername(r), + TemplateName: "album", + } + + err = templates.ExecuteTemplate(w, "base", albumData) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func editAlbumHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := getLoggedInUsername(r) + if username == "" { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + r.ParseForm() + title := r.Form.Get("title") + coverUrl := r.Form.Get("cover_url") + spotifyId := r.Form.Get("spotify_id") + musicbrainzId := r.Form.Get("musicbrainz_id") + + albumIdStr := chi.URLParam(r, "id") + albumId, err := strconv.Atoi(albumIdStr) + if err != nil { + http.Error(w, "Invalid album ID", http.StatusBadRequest) + return + } + + err = db.UpdateAlbum(albumId, title, coverUrl, spotifyId, musicbrainzId) + if err != nil { + fmt.Fprintf(os.Stderr, "Error updating album: %v\n", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/profile/"+username+"/album/"+url.QueryEscape(title), http.StatusSeeOther) + } +} + +type SearchResult struct { + Type string `json:"type"` + Name string `json:"name"` + Url string `json:"url"` + Count int `json:"count"` +} + +func searchHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + username := getLoggedInUsername(r) + if username == "" { + http.Error(w, "Not logged in", http.StatusUnauthorized) + return + } + + userId, err := getUserIdByUsername(r.Context(), username) + if err != nil { + http.Error(w, "User not found", http.StatusNotFound) + return + } + + query := r.URL.Query().Get("q") + if len(query) < 2 { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("[]")) + return + } + + var results []SearchResult + + artists, err := db.SearchArtists(userId, query) + if err == nil { + for _, a := range artists { + count, _ := db.GetArtistStats(userId, a.Id) + results = append(results, SearchResult{ + Type: "artist", + Name: a.Name, + Url: "/profile/" + username + "/artist/" + url.QueryEscape(a.Name), + Count: count, + }) + } + } + + songs, err := db.SearchSongs(userId, query) + if err == nil { + for _, s := range songs { + count, _ := db.GetSongStats(userId, s.Id) + results = append(results, SearchResult{ + Type: "song", + Name: s.Title, + Url: "/profile/" + username + "/song/" + url.QueryEscape(s.Title), + Count: count, + }) + } + } + + albums, err := db.SearchAlbums(userId, query) + if err == nil { + for _, al := range albums { + count, _ := db.GetAlbumStats(userId, al.Id) + results = append(results, SearchResult{ + Type: "album", + Name: al.Title, + Url: "/profile/" + username + "/album/" + url.QueryEscape(al.Title), + Count: count, + }) + } + } + + w.Header().Set("Content-Type", "application/json") + jsonBytes, _ := json.Marshal(results) + w.Write(jsonBytes) + } +} diff --git a/web/web.go b/web/web.go index eb4f3a8..c2d0d85 100644 --- a/web/web.go +++ b/web/web.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "net/http" + "net/url" "os" "muzi/config" @@ -35,6 +36,7 @@ func init() { "formatInt": formatInt, "formatTimestamp": formatTimestamp, "formatTimestampFull": formatTimestampFull, + "urlquery": url.QueryEscape, } templates = template.Must(template.New("").Funcs(funcMap).ParseGlob("./templates/*.gohtml")) } @@ -81,6 +83,13 @@ func Start() { r.Get("/login", loginPageHandler()) r.Get("/createaccount", createAccountPageHandler()) r.Get("/profile/{username}", profilePageHandler()) + r.Get("/profile/{username}/artist/{artist}", artistPageHandler()) + r.Get("/profile/{username}/song/{song}", songPageHandler()) + r.Get("/profile/{username}/album/{album}", albumPageHandler()) + r.Post("/profile/{username}/artist/{id}/edit", editArtistHandler()) + r.Post("/profile/{username}/song/{id}/edit", editSongHandler()) + r.Post("/profile/{username}/album/{id}/edit", editAlbumHandler()) + r.Get("/search", searchHandler()) r.Get("/import", importPageHandler()) r.Post("/loginsubmit", loginSubmit) r.Post("/createaccountsubmit", createAccount)