add top albums and top tracks widgets to profile

This commit is contained in:
2026-03-02 22:50:30 -08:00
parent 6e0e53eb64
commit 56475df1a0
7 changed files with 600 additions and 3 deletions

View File

@@ -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(),

View File

@@ -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();
});

View File

@@ -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 {

View File

@@ -157,6 +157,204 @@
</div>
{{end}}
</div>
<div class="top-albums">
<div class="top-albums-controls">
<h3>Top Albums</h3>
<div class="controls-row">
<label>
Period:
<select id="album-period-select" onchange="updateTopAlbums()">
<option value="all_time" {{if eq .TopAlbumsPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopAlbumsPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopAlbumsPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopAlbumsPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopAlbumsPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="album-custom-dates" style="display: {{if eq .TopAlbumsPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="album-start-date" onchange="updateTopAlbums()">
<input type="date" id="album-end-date" onchange="updateTopAlbums()">
</div>
<label>
Count:
<select id="album-limit-select" onchange="updateTopAlbums()">
<option value="5" {{if eq .TopAlbumsLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopAlbumsLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopAlbumsLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopAlbumsLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopAlbumsLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopAlbumsLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopAlbumsLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopAlbumsLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopAlbumsLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopAlbumsLimit 30}}selected{{end}}>30</option>
</select>
</label>
<label>
View:
<select id="album-view-select" onchange="updateTopAlbumsLimitOptions(); updateTopAlbums()">
<option value="grid" {{if eq .TopAlbumsView "grid"}}selected{{end}}>Grid</option>
<option value="list" {{if eq .TopAlbumsView "list"}}selected{{end}}>List</option>
</select>
</label>
</div>
</div>
{{if .TopAlbums}}
<div id="top-albums-display" class="top-albums-{{.TopAlbumsView}}">
{{$view := .TopAlbumsView}}
{{$albums := .TopAlbums}}
{{$len := len $albums}}
{{if eq $view "grid"}}
<div class="artist-grid {{if mod $len 2}}artist-grid-odd{{end}}">
{{if mod $len 2}}
<div class="artist-cell-first">
<a href="/profile/{{$.Username}}/album/{{urlquery (index $albums 0).Artist}}/{{urlquery (index $albums 0).AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if (index $albums 0).CoverUrl}}
<img src="{{(index $albums 0).CoverUrl}}" alt="{{(index $albums 0).AlbumName}}">
{{else}}
<div class="artist-placeholder"></div>
{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{(index $albums 0).AlbumName}}</p>
<p class="grid-items-item-aux-text">{{(index $albums 0).Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt (index $albums 0).ListenCount}} plays</p>
</div>
</a>
</div>
<div class="artist-right-col">
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums 1 (add 1 (div (sub $len 1) 2))}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums (add 1 (div (sub $len 1) 2)) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
</div>
{{else}}
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums 0 (div $len 2)}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
<div class="artist-row">
{{range $i, $a := sliceAlbum $albums (div $len 2) $len}}
<div class="artist-cell">
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="grid-items-cover-image">
<div class="grid-items-cover-image-image">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder"></div>{{end}}
</div>
<div class="grid-items-item-details">
<p class="grid-items-item-main-text">{{$a.AlbumName}}</p>
<p class="grid-items-item-aux-text">{{$a.Artist}}</p>
<p class="grid-items-item-aux-text">{{formatInt $a.ListenCount}} plays</p>
</div>
</a>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="artist-list">
{{range $a := $albums}}
<a href="/profile/{{$.Username}}/album/{{urlquery $a.Artist}}/{{urlquery $a.AlbumName}}" class="artist-row">
{{if $a.CoverUrl}}<img src="{{$a.CoverUrl}}" alt="{{$a.AlbumName}}">{{else}}<div class="artist-placeholder-row"></div>{{end}}
<span class="artist-name">{{$a.AlbumName}} - {{$a.Artist}}</span>
<span class="artist-count">{{formatInt $a.ListenCount}} plays</span>
</a>
{{end}}
</div>
{{end}}
</div>
{{end}}
</div>
<div class="top-tracks">
<div class="top-tracks-controls">
<h3>Top Tracks</h3>
<div class="controls-row">
<label>
Period:
<select id="track-period-select" onchange="updateTopTracks()">
<option value="all_time" {{if eq .TopTracksPeriod "all_time"}}selected{{end}}>All Time</option>
<option value="week" {{if eq .TopTracksPeriod "week"}}selected{{end}}>Last 7 Days</option>
<option value="month" {{if eq .TopTracksPeriod "month"}}selected{{end}}>Last 30 Days</option>
<option value="year" {{if eq .TopTracksPeriod "year"}}selected{{end}}>Last Year</option>
<option value="custom" {{if eq .TopTracksPeriod "custom"}}selected{{end}}>Custom</option>
</select>
</label>
<div id="track-custom-dates" style="display: {{if eq .TopTracksPeriod "custom"}}inline-block{{else}}none{{end}};">
<input type="date" id="track-start-date" onchange="updateTopTracks()">
<input type="date" id="track-end-date" onchange="updateTopTracks()">
</div>
<label>
Count:
<select id="track-limit-select" onchange="updateTopTracks()">
<option value="5" {{if eq .TopTracksLimit 5}}selected{{end}}>5</option>
<option value="6" {{if eq .TopTracksLimit 6}}selected{{end}}>6</option>
<option value="7" {{if eq .TopTracksLimit 7}}selected{{end}}>7</option>
<option value="8" {{if eq .TopTracksLimit 8}}selected{{end}}>8</option>
<option value="9" {{if eq .TopTracksLimit 9}}selected{{end}}>9</option>
<option value="10" {{if eq .TopTracksLimit 10}}selected{{end}}>10</option>
<option value="15" {{if eq .TopTracksLimit 15}}selected{{end}}>15</option>
<option value="20" {{if eq .TopTracksLimit 20}}selected{{end}}>20</option>
<option value="25" {{if eq .TopTracksLimit 25}}selected{{end}}>25</option>
<option value="30" {{if eq .TopTracksLimit 30}}selected{{end}}>30</option>
</select>
</label>
</div>
</div>
{{if .TopTracks}}
<div id="top-tracks-display">
{{$tracks := .TopTracks}}
<div class="artist-list">
{{range $t := $tracks}}
<a href="/profile/{{$.Username}}/song/{{urlquery $t.Artist}}/{{urlquery $t.SongName}}" class="artist-row">
<span class="artist-name">{{$t.SongName}} - {{$t.Artist}}</span>
<span class="artist-count">{{formatInt $t.ListenCount}} plays</span>
</a>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="history">
<h3>Listening History</h3>
<table>

View File

@@ -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

View File

@@ -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

View File

@@ -36,6 +36,8 @@ func init() {
"div": div,
"mod": mod,
"slice": slice,
"sliceAlbum": sliceAlbum,
"sliceTrack": sliceTrack,
"gridReorder": gridReorder,
"formatInt": formatInt,
"formatTimestamp": formatTimestamp,