From 99185499b1089fa17324d88274dbf0a410e505a5 Mon Sep 17 00:00:00 2001 From: riwiwa Date: Sat, 28 Feb 2026 21:18:41 -0800 Subject: [PATCH] Add search and pages for tracks, albums, and artists --- db/db.go | 107 ++++- db/entities.go | 621 ++++++++++++++++++++++++++ scrobble/scrobble.go | 27 +- static/assets/pfps/default_artist.png | Bin 0 -> 7915 bytes static/menu.js | 63 +++ static/style.css | 97 ++++ templates/album.gohtml | 58 +++ templates/artist.gohtml | 57 +++ templates/base.gohtml | 9 + templates/profile.gohtml | 13 +- templates/song.gohtml | 55 +++ web/entity.go | 445 ++++++++++++++++++ web/web.go | 9 + 13 files changed, 1551 insertions(+), 10 deletions(-) create mode 100644 db/entities.go create mode 100644 static/assets/pfps/default_artist.png create mode 100644 templates/album.gohtml create mode 100644 templates/artist.gohtml create mode 100644 templates/song.gohtml create mode 100644 web/entity.go 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 0000000000000000000000000000000000000000..0fd5d107127e9025c4baf26d75346b9ff3cfb7e1 GIT binary patch literal 7915 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 ZazUS=<=`Glg15=YzCEPf1y-jR{{^)>783vf literal 0 HcmV?d00001 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)