From 6e0e53eb64d3a44dfdb8d21b2cf297222bf04ca3 Mon Sep 17 00:00:00 2001 From: riwiwa Date: Mon, 2 Mar 2026 21:58:41 -0800 Subject: [PATCH] add top artists display to profile --- db/entities.go | 75 +++++++ static/profile.js | 60 ++++++ static/style.css | 447 +++++++++++++++++++++++++++++++++++++++ templates/base.gohtml | 3 + templates/profile.gohtml | 146 ++++++++++++- web/profile.go | 70 ++++++ web/utils.go | 43 ++++ web/web.go | 4 + 8 files changed, 847 insertions(+), 1 deletion(-) create mode 100644 static/profile.js diff --git a/db/entities.go b/db/entities.go index c095631..dc39ef8 100644 --- a/db/entities.go +++ b/db/entities.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgtype" + "github.com/jackc/pgx/v5" ) type Artist struct { @@ -504,6 +505,80 @@ func GetArtistStats(userId, artistId int) (int, error) { return count, err } +type TopArtist struct { + Artist Artist + ListenCount int +} + +func GetTopArtists(userId int, limit int, startDate, endDate *time.Time) ([]TopArtist, error) { + var err error + var rows pgx.Rows + + if startDate == nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count + FROM artists a + JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids) + WHERE h.user_id = $1 + GROUP BY a.id + ORDER BY listen_count DESC + LIMIT $2`, + userId, limit) + } else if startDate != nil && endDate == nil { + rows, err = Pool.Query(context.Background(), + `SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count + FROM artists a + JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids) + WHERE h.user_id = $1 AND h.timestamp >= $2 + GROUP BY a.id + ORDER BY listen_count DESC + LIMIT $3`, + userId, startDate, limit) + } else if startDate == nil && endDate != nil { + rows, err = Pool.Query(context.Background(), + `SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count + FROM artists a + JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids) + WHERE h.user_id = $1 AND h.timestamp <= $2 + GROUP BY a.id + ORDER BY listen_count DESC + LIMIT $3`, + userId, endDate, limit) + } else { + rows, err = Pool.Query(context.Background(), + `SELECT a.id, a.user_id, a.name, a.image_url, a.bio, a.spotify_id, a.musicbrainz_id, COUNT(*) as listen_count + FROM artists a + JOIN history h ON h.user_id = a.user_id AND a.id = ANY(h.artist_ids) + WHERE h.user_id = $1 AND h.timestamp >= $2 AND h.timestamp <= $3 + GROUP BY a.id + ORDER BY listen_count DESC + LIMIT $4`, + userId, startDate, endDate, limit) + } + + if err != nil { + return nil, err + } + defer rows.Close() + + var topArtists []TopArtist + for rows.Next() { + var a Artist + var count int + var imageUrlPg, bioPg, spotifyIdPg, musicbrainzIdPg pgtype.Text + err := rows.Scan(&a.Id, &a.UserId, &a.Name, &imageUrlPg, &bioPg, &spotifyIdPg, &musicbrainzIdPg, &count) + if err != nil { + return nil, err + } + a.ImageUrl = imageUrlPg.String + a.Bio = bioPg.String + a.SpotifyId = spotifyIdPg.String + a.MusicbrainzId = musicbrainzIdPg.String + topArtists = append(topArtists, TopArtist{Artist: a, ListenCount: count}) + } + return topArtists, 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 new file mode 100644 index 0000000..44a0d50 --- /dev/null +++ b/static/profile.js @@ -0,0 +1,60 @@ +function updateTopArtists() { + const period = document.getElementById('period-select').value; + const limit = document.getElementById('limit-select').value; + const view = document.getElementById('view-select').value; + + const customDates = document.getElementById('custom-dates'); + if (period === 'custom') { + customDates.style.display = 'inline-block'; + } else { + customDates.style.display = 'none'; + } + + const params = new URLSearchParams(window.location.search); + params.set('period', period); + params.set('limit', limit); + params.set('view', view); + + if (period === 'custom') { + const startDate = document.getElementById('start-date').value; + const endDate = document.getElementById('end-date').value; + if (startDate) params.set('start', startDate); + if (endDate) params.set('end', endDate); + } + + window.location.search = params.toString(); +} + +function updateLimitOptions() { + const view = document.getElementById('view-select').value; + const limitSelect = document.getElementById('limit-select'); + const maxLimit = view === 'grid' ? 8 : 30; + + for (let option of limitSelect.options) { + const value = parseInt(option.value); + if (value > maxLimit) { + option.style.display = 'none'; + } else { + option.style.display = ''; + } + } + + if (parseInt(limitSelect.value) > maxLimit) { + limitSelect.value = maxLimit; + } +} + +function syncGridHeights() {} + +document.addEventListener('DOMContentLoaded', function() { + const customDates = document.getElementById('custom-dates'); + const periodSelect = document.getElementById('period-select'); + + if (periodSelect && customDates) { + if (periodSelect.value === 'custom') { + customDates.style.display = 'inline-block'; + } + } + + updateLimitOptions(); +}); diff --git a/static/style.css b/static/style.css index da2be00..b386932 100644 --- a/static/style.css +++ b/static/style.css @@ -760,3 +760,450 @@ a.button:hover { margin: 0; line-height: 1.6; } + +/* Top Artists Section */ +.top-artists { + width: 50%; + margin: 15px 0; + padding: 10px; + background: #1a1a1a; + border-radius: 10px; +} + +.top-artists-controls { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + +#top-artists-display { + min-height: 150px; +} + +.top-artists-controls h3 { + margin: 0; + color: #fff; +} + +.controls-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + max-width: 100%; +} + +.controls-row label { + display: flex; + align-items: center; + gap: 8px; + color: #888; + font-size: 14px; +} + +.controls-row select { + padding: 6px 10px; + border: 1px solid #444; + border-radius: 4px; + background: #333; + color: #AFA; + font-size: 14px; + cursor: pointer; +} + +.controls-row select:hover { + border-color: #AFA; +} + +.controls-row input[type="date"] { + padding: 6px 10px; + border: 1px solid #444; + border-radius: 4px; + background: #333; + color: #AFA; + font-size: 14px; +} + +/* Artist Grid - New layout */ +.artist-grid { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + padding: 0; + margin: 0; +} + +.artist-grid-odd { + flex-direction: row; +} + +.artist-grid-odd .artist-cell-first { + aspect-ratio: 1; + flex: 1; +} + +.artist-grid-odd .artist-right-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.artist-row { + display: flex; + gap: 8px; + flex: 1; +} + +.artist-grid-odd .artist-row { + flex: 1; +} + +.artist-cell { + flex: 1; + display: block; + background: #2a2a2a; + transition: background 0.2s; + transition: transform 0.1s ease; + border-radius: 8px; + min-width: 0; +} + +.artist-cell-first { + display: block; + transition: background 0.2s; + transition: transform 0.1s ease; + border-radius: 8px; +} + +.artist-cell a, +.artist-cell-first a { + display: block; + text-decoration: none; +} + +.artist-cell, +.artist-cell-first { + position: relative; + aspect-ratio: 1; +} + +.artist-cell .grid-items-cover-image, +.artist-cell-first .grid-items-cover-image { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.artist-cell .grid-items-cover-image-image, +.artist-cell-first .grid-items-cover-image-image { + position: relative; + width: 100%; + height: 100%; +} + +.artist-cell .grid-items-cover-image-image img, +.artist-cell-first .grid-items-cover-image-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + border-radius: 10px; +} + +.artist-cell .artist-placeholder, +.artist-cell-first .artist-placeholder { + width: 100%; + height: 100%; + background: #333; + border-radius: 10px; +} + +.artist-cell:hover .grid-items-cover-image-image img, +.artist-cell-first:hover .grid-items-cover-image-image img, +.artist-cell:hover .artist-placeholder, +.artist-cell-first:hover .artist-placeholder { + opacity: 0.9; +} + +.artist-cell .grid-items-item-details, +.artist-cell-first .grid-items-item-details { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 8px; + background: linear-gradient(#0000008c, rgba(0,0,0,0.8)); + box-sizing: border-box; + border-radius: 0 0 10px 10px; +} + +.artist-cell .grid-items-item-main-text, +.artist-cell-first .grid-items-item-main-text { + color: #fff; + font-weight: bold; + font-size: 14px; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.artist-cell .grid-items-item-aux-text, +.artist-cell-first .grid-items-item-aux-text { + color: #aaa; + font-size: 12px; + margin: 0; +} + +/* List View */ +.artist-list { + display: flex; + flex-direction: column; + gap: 6px; +} + overflow: hidden; + text-decoration: none; +} + +.grid-large .artist-card-large img, +.grid-large .artist-card-large .artist-placeholder-large { + width: 100%; + height: 100%; + object-fit: cover; +} + +.grid-large .small-group { + display: contents; +} + +.grid-large .small-row { + display: contents; +} + +.grid-large .artist-card-small { + position: relative; + border-radius: 8px; + overflow: hidden; + text-decoration: none; +} + +.grid-large .artist-card-small img, +.grid-large .artist-card-small .artist-placeholder-small { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; +} +} + +.artist-placeholder-large { + width: 100%; + height: 100%; + min-height: 300px; + background: #333; + border-radius: 8px; +} + +.grid-small-group { + display: flex; + flex-direction: column; + gap: 10px; +} + +.artist-card-large, +.artist-card-small { + display: block; + position: relative; + border-radius: 8px; + overflow: hidden; + text-decoration: none; +} + +.artist-card-large img, +.artist-card-small img { + width: 100%; + height: 100px; + object-fit: cover; + transition: transform 0.2s; +} + +.artist-card-large:hover img, +.artist-card-small:hover img { + transform: scale(1.05); +} + +.artist-card-large img, +.artist-card-small img { + width: 100%; + height: 200px; + object-fit: cover; + transition: transform 0.2s; +} + +.artist-card-large:hover img, +.artist-card-small:hover img { + transform: scale(1.05); +} + +.artist-placeholder-small { + width: 100%; + height: 150px; + background: #333; + border-radius: 8px; +} + +.artist-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 12px 6px 4px; + background: linear-gradient(transparent, rgba(0,0,0,0.9)); + display: flex; + flex-direction: column; + gap: 1px; +} + +.artist-name { + color: #fff; + font-weight: bold; + font-size: 9px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.artist-count { + color: #AFA; + font-size: 8px; +} + +/* Grid View - Even */ +.grid-even { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.grid-even .small-row { + display: contents; +} + +.grid-even .artist-card-small { + position: relative; + border-radius: 8px; + overflow: hidden; + text-decoration: none; +} + +.grid-even .artist-card-small img, +.grid-even .artist-card-small .artist-placeholder-small { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + display: block; +} + +.grid-even .artist-card-small img, +.grid-even .artist-card-small .artist-placeholder-small { + width: 100%; + height: 100px; + object-fit: cover; +} + +/* List View */ +.artist-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.artist-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px; + border-radius: 8px; + text-decoration: none; +} + +.artist-cell:hover { + background: #333; + transform: scale(0.98); +} + +.artist-cell-first:hover { + background: #333; + transform: scale(0.98); +} + +.artist-row img { + width: 35px; + height: 35px; + object-fit: cover; + border-radius: 50%; + flex-shrink: 0; +} + +.artist-placeholder-row { + width: 35px; + height: 35px; + background: #444; + border-radius: 50%; + flex-shrink: 0; +} + +.artist-row .artist-name { + flex: 1; + text-align: left; + font-size: 12px; +} + +.artist-row .artist-count { + text-align: right; + font-size: 11px; +} + +/* Responsive Artist Grid */ +@media (max-width: 600px) { + .artist-grid { + max-width: 100%; + } + + .artist-grid-odd { + flex-direction: column; + } + + .artist-grid-odd .artist-cell-first { + width: 100%; + max-width: 300px; + margin: 0 auto; + } + + .artist-grid-odd .artist-right-col { + flex-direction: row; + } + + .artist-row { + flex-wrap: wrap; + } + + .artist-cell { + min-width: calc(50% - 4px); + } +} + +@media (max-width: 400px) { + .artist-grid-odd .artist-right-col { + flex-direction: column; + } + + .artist-cell { + min-width: 100%; + } +} diff --git a/templates/base.gohtml b/templates/base.gohtml index 4bc10b0..61fe3f8 100644 --- a/templates/base.gohtml +++ b/templates/base.gohtml @@ -55,6 +55,9 @@ {{ if eq .TemplateName "album"}}{{block "album" .}}{{end}}{{end}} + {{if eq .TemplateName "profile"}} + + {{end}} {{end}} diff --git a/templates/profile.gohtml b/templates/profile.gohtml index 9ccbb6c..d444fa7 100644 --- a/templates/profile.gohtml +++ b/templates/profile.gohtml @@ -13,6 +13,150 @@

{{formatInt .ArtistCount}}

Artists

+

+
+

Top Artists

+
+ +
+ + +
+ + +
+
+ {{if .TopArtists}} +
+ {{$view := .TopArtistsView}} + {{$artists := .TopArtists}} + {{$len := len $artists}} + {{if eq $view "grid"}} + + {{else}} + + {{end}} +
+ {{end}} +

Listening History

@@ -39,7 +183,7 @@ {{- range $i, $name := $artistNames}}{{if $i}}, {{end}}{{$name}}{{end}} - + {{end}}
{{$title}}{{formatTimestamp (index $times $index)}}{{formatTimestamp (index $times $index)}}
diff --git a/web/profile.go b/web/profile.go index 83fe7dd..391be6d 100644 --- a/web/profile.go +++ b/web/profile.go @@ -34,6 +34,10 @@ type ProfileData struct { TemplateName string NowPlayingArtist string NowPlayingTitle string + TopArtists []db.TopArtist + TopArtistsPeriod string + TopArtistsLimit int + TopArtistsView string } // Render a page of the profile in the URL @@ -85,6 +89,72 @@ func profilePageHandler() http.HandlerFunc { return } + period := r.URL.Query().Get("period") + if period == "" { + period = "all_time" + } + + view := r.URL.Query().Get("view") + if view == "" { + view = "grid" + } + + maxLimit := 30 + if view == "grid" { + maxLimit = 8 + } + + limitStr := r.URL.Query().Get("limit") + limit := 10 + if limitStr != "" { + limit, err = strconv.Atoi(limitStr) + if err != nil || limit < 5 { + limit = 10 + } + if limit > maxLimit { + limit = maxLimit + } + } + + profileData.TopArtistsPeriod = period + profileData.TopArtistsLimit = limit + profileData.TopArtistsView = view + + var startDate, endDate *time.Time + now := time.Now() + switch period { + case "week": + start := now.AddDate(0, 0, -7) + startDate = &start + case "month": + start := now.AddDate(0, -1, 0) + startDate = &start + case "year": + start := now.AddDate(-1, 0, 0) + startDate = &start + case "custom": + startStr := r.URL.Query().Get("start") + endStr := r.URL.Query().Get("end") + if startStr != "" { + if t, err := time.Parse("2006-01-02", startStr); err == nil { + startDate = &t + } + } + if endStr != "" { + if t, err := time.Parse("2006-01-02", endStr); err == nil { + t = t.AddDate(0, 0, 1) + endDate = &t + } + } + } + + topArtists, err := db.GetTopArtists(userId, limit, startDate, endDate) + if err != nil { + fmt.Fprintf(os.Stderr, "Cannot get top artists: %v\n", err) + } else { + profileData.TopArtists = topArtists + } + 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 7b53a53..f4a8948 100644 --- a/web/utils.go +++ b/web/utils.go @@ -19,6 +19,49 @@ func add(a int, b int) int { return a + b } +// Divides two integers (integer division) +func div(a int, b int) int { + if b == 0 { + return 0 + } + return a / b +} + +// Returns a % b +func mod(a int, b int) int { + return a % b +} + +// Returns a slice of a slice from start to end +func slice(a []db.TopArtist, start int, end int) []db.TopArtist { + if start >= len(a) { + return []db.TopArtist{} + } + if end > len(a) { + end = len(a) + } + return a[start:end] +} + +func gridReorder(artists []db.TopArtist) []db.TopArtist { + if len(artists) < 2 { + return artists + } + if len(artists)%2 == 0 { + return artists + } + remaining := len(artists) - 1 + perRow := remaining / 2 + rest := artists[1:] + firstRow := rest[:perRow] + secondRow := rest[perRow:] + var reordered []db.TopArtist + reordered = append(reordered, artists[0]) + reordered = append(reordered, secondRow...) + reordered = append(reordered, firstRow...) + return reordered +} + // Put a comma in the thousands place, ten-thousands place etc. func formatInt(n int) string { if n < 1000 { diff --git a/web/web.go b/web/web.go index f770589..08da6e7 100644 --- a/web/web.go +++ b/web/web.go @@ -33,6 +33,10 @@ func init() { funcMap := template.FuncMap{ "sub": sub, "add": add, + "div": div, + "mod": mod, + "slice": slice, + "gridReorder": gridReorder, "formatInt": formatInt, "formatTimestamp": formatTimestamp, "formatTimestampFull": formatTimestampFull,