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 = '
+ {{end}}
+ Listens
+
| Artist | +Title | +Album | +Timestamp | +
|---|---|---|---|
| {{.ArtistName}} | +{{.SongName}} | +{{.AlbumName}} | +{{formatTimestamp .Timestamp}} | +
+ {{end}}
+ Listens
+
| Artist | +Title | +Album | +Timestamp | +
|---|---|---|---|
| {{.ArtistName}} | +{{.SongName}} | +{{.AlbumName}} | +{{formatTimestamp .Timestamp}} | +