diff --git a/db/entities.go b/db/entities.go index dc39ef8..6829863 100644 --- a/db/entities.go +++ b/db/entities.go @@ -510,6 +510,19 @@ type TopArtist struct { ListenCount int } +type TopAlbum struct { + AlbumName string + Artist string + CoverUrl string + ListenCount int +} + +type TopTrack struct { + SongName string + Artist string + ListenCount int +} + func GetTopArtists(userId int, limit int, startDate, endDate *time.Time) ([]TopArtist, error) { var err error var rows pgx.Rows @@ -579,6 +592,130 @@ func GetTopArtists(userId int, limit int, startDate, endDate *time.Time) ([]TopA return topArtists, nil } +func GetTopAlbums(userId int, limit int, startDate, endDate *time.Time) ([]TopAlbum, error) { + var err error + var rows pgx.Rows + + if startDate == nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count + FROM history h + LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist) + WHERE h.user_id = $1 AND h.album_name IS NOT NULL AND h.album_name != '' + GROUP BY h.album_name, h.artist, a.cover_url + ORDER BY listen_count DESC + LIMIT $2`, + userId, limit) + } else if startDate != nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count + FROM history h + LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist) + WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.album_name IS NOT NULL AND h.album_name != '' + GROUP BY h.album_name, h.artist, a.cover_url + ORDER BY listen_count DESC + LIMIT $3`, + userId, startDate, limit) + } else if startDate == nil && endDate != nil { + rows, err = Pool.Query(context.Background(), + `SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count + FROM history h + LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist) + WHERE h.user_id = $1 AND h.timestamp <= $2 AND h.album_name IS NOT NULL AND h.album_name != '' + GROUP BY h.album_name, h.artist, a.cover_url + ORDER BY listen_count DESC + LIMIT $3`, + userId, endDate, limit) + } else { + rows, err = Pool.Query(context.Background(), + `SELECT h.album_name, h.artist, COALESCE(a.cover_url, ''), COUNT(*) as listen_count + FROM history h + LEFT JOIN albums a ON a.user_id = h.user_id AND a.title = h.album_name AND a.artist_id IN (SELECT id FROM artists WHERE user_id = h.user_id AND name = h.artist) + WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.timestamp <= $3 AND h.album_name IS NOT NULL AND h.album_name != '' + GROUP BY h.album_name, h.artist, a.cover_url + ORDER BY listen_count DESC + LIMIT $4`, + userId, startDate, endDate, limit) + } + + if err != nil { + return nil, err + } + defer rows.Close() + + var topAlbums []TopAlbum + for rows.Next() { + var albumName, artist, coverUrl string + var count int + err := rows.Scan(&albumName, &artist, &coverUrl, &count) + if err != nil { + return nil, err + } + topAlbums = append(topAlbums, TopAlbum{AlbumName: albumName, Artist: artist, CoverUrl: coverUrl, ListenCount: count}) + } + return topAlbums, nil +} + +func GetTopTracks(userId int, limit int, startDate, endDate *time.Time) ([]TopTrack, error) { + var err error + var rows pgx.Rows + + if startDate == nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT song_name, artist, COUNT(*) as listen_count + FROM history + WHERE user_id = $1 + GROUP BY song_name, artist + ORDER BY listen_count DESC + LIMIT $2`, + userId, limit) + } else if startDate != nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT song_name, artist, COUNT(*) as listen_count + FROM history + WHERE user_id = $1 AND timestamp >= $2 + GROUP BY song_name, artist + ORDER BY listen_count DESC + LIMIT $3`, + userId, startDate, limit) + } else if startDate == nil && endDate != nil { + rows, err = Pool.Query(context.Background(), + `SELECT song_name, artist, COUNT(*) as listen_count + FROM history + WHERE user_id = $1 AND timestamp <= $2 + GROUP BY song_name, artist + ORDER BY listen_count DESC + LIMIT $3`, + userId, endDate, limit) + } else { + rows, err = Pool.Query(context.Background(), + `SELECT song_name, artist, COUNT(*) as listen_count + FROM history + WHERE user_id = $1 AND timestamp >= $2 AND timestamp <= $3 + GROUP BY song_name, artist + ORDER BY listen_count DESC + LIMIT $4`, + userId, startDate, endDate, limit) + } + + if err != nil { + return nil, err + } + defer rows.Close() + + var topTracks []TopTrack + for rows.Next() { + var songName, artist string + var count int + err := rows.Scan(&songName, &artist, &count) + if err != nil { + return nil, err + } + topTracks = append(topTracks, TopTrack{SongName: songName, Artist: artist, ListenCount: count}) + } + return topTracks, nil +} + func GetSongStats(userId, songId int) (int, error) { var count int err := Pool.QueryRow(context.Background(), diff --git a/static/profile.js b/static/profile.js index 44a0d50..647c322 100644 --- a/static/profile.js +++ b/static/profile.js @@ -32,18 +32,89 @@ function updateLimitOptions() { for (let option of limitSelect.options) { const value = parseInt(option.value); - if (value > maxLimit) { + if (value > maxLimit || (view === 'grid' && value === 7)) { option.style.display = 'none'; } else { option.style.display = ''; } } - if (parseInt(limitSelect.value) > maxLimit) { + if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) { limitSelect.value = maxLimit; } } +function updateTopAlbums() { + const period = document.getElementById('album-period-select').value; + const limit = document.getElementById('album-limit-select').value; + const view = document.getElementById('album-view-select').value; + + const customDates = document.getElementById('album-custom-dates'); + if (period === 'custom') { + customDates.style.display = 'inline-block'; + } else { + customDates.style.display = 'none'; + } + + const params = new URLSearchParams(window.location.search); + params.set('album_period', period); + params.set('album_limit', limit); + params.set('album_view', view); + + if (period === 'custom') { + const startDate = document.getElementById('album-start-date').value; + const endDate = document.getElementById('album-end-date').value; + if (startDate) params.set('album_start', startDate); + if (endDate) params.set('album_end', endDate); + } + + window.location.search = params.toString(); +} + +function updateTopAlbumsLimitOptions() { + const view = document.getElementById('album-view-select').value; + const limitSelect = document.getElementById('album-limit-select'); + const maxLimit = view === 'grid' ? 8 : 30; + + for (let option of limitSelect.options) { + const value = parseInt(option.value); + if (value > maxLimit || (view === 'grid' && value === 7)) { + option.style.display = 'none'; + } else { + option.style.display = ''; + } + } + + if (parseInt(limitSelect.value) > maxLimit || (view === 'grid' && parseInt(limitSelect.value) === 7)) { + limitSelect.value = maxLimit; + } +} + +function updateTopTracks() { + const period = document.getElementById('track-period-select').value; + const limit = document.getElementById('track-limit-select').value; + + const customDates = document.getElementById('track-custom-dates'); + if (period === 'custom') { + customDates.style.display = 'inline-block'; + } else { + customDates.style.display = 'none'; + } + + const params = new URLSearchParams(window.location.search); + params.set('track_period', period); + params.set('track_limit', limit); + + if (period === 'custom') { + const startDate = document.getElementById('track-start-date').value; + const endDate = document.getElementById('track-end-date').value; + if (startDate) params.set('track_start', startDate); + if (endDate) params.set('track_end', endDate); + } + + window.location.search = params.toString(); +} + function syncGridHeights() {} document.addEventListener('DOMContentLoaded', function() { @@ -57,4 +128,6 @@ document.addEventListener('DOMContentLoaded', function() { } updateLimitOptions(); + + updateTopAlbumsLimitOptions(); }); diff --git a/static/style.css b/static/style.css index b386932..a3269c7 100644 --- a/static/style.css +++ b/static/style.css @@ -770,6 +770,22 @@ a.button:hover { border-radius: 10px; } +.top-albums { + width: 50%; + margin: 15px 0; + padding: 10px; + background: #1a1a1a; + border-radius: 10px; +} + +.top-tracks { + width: 50%; + margin: 15px 0; + padding: 10px; + background: #1a1a1a; + border-radius: 10px; +} + .top-artists-controls { display: flex; flex-direction: column; @@ -777,13 +793,34 @@ a.button:hover { margin-bottom: 10px; } +.top-albums-controls { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + +.top-tracks-controls { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + #top-artists-display { min-height: 150px; } +#top-albums-display { + min-height: 150px; +} + +#top-tracks-display { + min-height: 150px; +} + .top-artists-controls h3 { margin: 0; - color: #fff; } .controls-row { diff --git a/templates/profile.gohtml b/templates/profile.gohtml index d444fa7..3d9fb41 100644 --- a/templates/profile.gohtml +++ b/templates/profile.gohtml @@ -157,6 +157,204 @@ {{end}} +
+
+

Top Albums

+
+ +
+ + +
+ + +
+
+ {{if .TopAlbums}} +
+ {{$view := .TopAlbumsView}} + {{$albums := .TopAlbums}} + {{$len := len $albums}} + {{if eq $view "grid"}} + + {{else}} + + {{end}} +
+ {{end}} +
+
+
+

Top Tracks

+
+ +
+ + +
+ +
+
+ {{if .TopTracks}} +
+ {{$tracks := .TopTracks}} + +
+ {{end}} +

Listening History

diff --git a/web/profile.go b/web/profile.go index 391be6d..3d107f7 100644 --- a/web/profile.go +++ b/web/profile.go @@ -38,6 +38,13 @@ type ProfileData struct { TopArtistsPeriod string TopArtistsLimit int TopArtistsView string + TopAlbums []db.TopAlbum + TopAlbumsPeriod string + TopAlbumsLimit int + TopAlbumsView string + TopTracks []db.TopTrack + TopTracksPeriod string + TopTracksLimit int } // Render a page of the profile in the URL @@ -155,6 +162,129 @@ func profilePageHandler() http.HandlerFunc { profileData.TopArtists = topArtists } + albumPeriod := r.URL.Query().Get("album_period") + if albumPeriod == "" { + albumPeriod = "all_time" + } + + var albumStartDate, albumEndDate *time.Time + albumNow := time.Now() + switch albumPeriod { + case "week": + start := albumNow.AddDate(0, 0, -7) + albumStartDate = &start + case "month": + start := albumNow.AddDate(0, -1, 0) + albumStartDate = &start + case "year": + start := albumNow.AddDate(-1, 0, 0) + albumStartDate = &start + case "custom": + albumStartStr := r.URL.Query().Get("album_start") + albumEndStr := r.URL.Query().Get("album_end") + if albumStartStr != "" { + if t, err := time.Parse("2006-01-02", albumStartStr); err == nil { + albumStartDate = &t + } + } + if albumEndStr != "" { + if t, err := time.Parse("2006-01-02", albumEndStr); err == nil { + t = t.AddDate(0, 0, 1) + albumEndDate = &t + } + } + } + + albumLimitStr := r.URL.Query().Get("album_limit") + albumLimit := 10 + if albumLimitStr != "" { + albumLimit, err = strconv.Atoi(albumLimitStr) + if err != nil || albumLimit < 5 { + albumLimit = 10 + } + if albumLimit > 30 { + albumLimit = 30 + } + } + + albumView := r.URL.Query().Get("album_view") + if albumView == "" { + albumView = "grid" + } + albumMaxLimit := 30 + if albumView == "grid" { + albumMaxLimit = 8 + } + if albumLimit > albumMaxLimit { + albumLimit = albumMaxLimit + } + + profileData.TopAlbumsPeriod = albumPeriod + profileData.TopAlbumsLimit = albumLimit + profileData.TopAlbumsView = albumView + + topAlbums, err := db.GetTopAlbums(userId, albumLimit, albumStartDate, albumEndDate) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get top albums: %v\n", err) + } else { + profileData.TopAlbums = topAlbums + } + + trackPeriod := r.URL.Query().Get("track_period") + if trackPeriod == "" { + trackPeriod = "all_time" + } + + var trackStartDate, trackEndDate *time.Time + trackNow := time.Now() + switch trackPeriod { + case "week": + start := trackNow.AddDate(0, 0, -7) + trackStartDate = &start + case "month": + start := trackNow.AddDate(0, -1, 0) + trackStartDate = &start + case "year": + start := trackNow.AddDate(-1, 0, 0) + trackStartDate = &start + case "custom": + trackStartStr := r.URL.Query().Get("track_start") + trackEndStr := r.URL.Query().Get("track_end") + if trackStartStr != "" { + if t, err := time.Parse("2006-01-02", trackStartStr); err == nil { + trackStartDate = &t + } + } + if trackEndStr != "" { + if t, err := time.Parse("2006-01-02", trackEndStr); err == nil { + t = t.AddDate(0, 0, 1) + trackEndDate = &t + } + } + } + + trackLimitStr := r.URL.Query().Get("track_limit") + trackLimit := 10 + if trackLimitStr != "" { + trackLimit, err = strconv.Atoi(trackLimitStr) + if err != nil || trackLimit < 5 { + trackLimit = 10 + } + if trackLimit > 30 { + trackLimit = 30 + } + } + + profileData.TopTracksPeriod = trackPeriod + profileData.TopTracksLimit = trackLimit + + topTracks, err := db.GetTopTracks(userId, trackLimit, trackStartDate, trackEndDate) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get top tracks: %v\n", err) + } else { + profileData.TopTracks = topTracks + } + if pageInt == 1 { if np, ok := scrobble.GetNowPlaying(userId); ok { profileData.NowPlayingArtist = np.Artist diff --git a/web/utils.go b/web/utils.go index f4a8948..176b814 100644 --- a/web/utils.go +++ b/web/utils.go @@ -43,6 +43,26 @@ func slice(a []db.TopArtist, start int, end int) []db.TopArtist { return a[start:end] } +func sliceAlbum(a []db.TopAlbum, start int, end int) []db.TopAlbum { + if start >= len(a) { + return []db.TopAlbum{} + } + if end > len(a) { + end = len(a) + } + return a[start:end] +} + +func sliceTrack(a []db.TopTrack, start int, end int) []db.TopTrack { + if start >= len(a) { + return []db.TopTrack{} + } + if end > len(a) { + end = len(a) + } + return a[start:end] +} + func gridReorder(artists []db.TopArtist) []db.TopArtist { if len(artists) < 2 { return artists diff --git a/web/web.go b/web/web.go index 08da6e7..619647c 100644 --- a/web/web.go +++ b/web/web.go @@ -36,6 +36,8 @@ func init() { "div": div, "mod": mod, "slice": slice, + "sliceAlbum": sliceAlbum, + "sliceTrack": sliceTrack, "gridReorder": gridReorder, "formatInt": formatInt, "formatTimestamp": formatTimestamp,