feat: top releases sorted by popularity with library-style album cards

Replaced the date-sorted release group approach with a dedicated
TopReleaseGroupsForArtist LB API call that returns releases ranked
by total listen count (popularity). Added LBTopReleaseGroup type
and Wails bindings.

Restyled the top-releases cards to match the library album view:
square cover art on top with title and type below, centered text,
auto-filling the available width with even spacing.
This commit is contained in:
2026-03-29 17:12:30 -04:00
parent 4138fe593d
commit cef6709d9a
7 changed files with 180 additions and 39 deletions
+5
View File
@@ -184,6 +184,11 @@ func (e *Service) TopRecordingsForArtist(artistMBID string) ([]LBTopRecording, e
return e.lb.TopRecordingsForArtist(e.ctx, artistMBID)
}
// TopReleaseGroupsForArtist returns the most-listened release groups for an artist.
func (e *Service) TopReleaseGroupsForArtist(artistMBID string) ([]LBTopReleaseGroup, error) {
return e.lb.TopReleaseGroupsForArtist(e.ctx, artistMBID)
}
// SimilarArtists returns artists similar to the given artist MBID.
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
return e.lb.SimilarArtists(e.ctx, artistMBID)
+46
View File
@@ -97,6 +97,52 @@ func (c *ListenBrainzClient) TopRecordingsForArtist(
return out, nil
}
// TopReleaseGroupsForArtist returns the most-listened release groups
// for the artist identified by artistMBID.
func (c *ListenBrainzClient) TopReleaseGroupsForArtist(
ctx context.Context, artistMBID string,
) ([]LBTopReleaseGroup, error) {
url := fmt.Sprintf(
"%s/1/popularity/top-release-groups-for-artist/%s",
listenBrainzBaseURL,
artistMBID,
)
cacheKey := "lb:top-release-groups:" + artistMBID
if data, ok := c.cache.Get(cacheKey); ok {
var out []LBTopReleaseGroup
if err := json.Unmarshal(data, &out); err == nil {
return out, nil
}
}
body, err := c.doGet(ctx, url)
if err != nil {
return nil, fmt.Errorf("listenbrainz top release groups: %w", err)
}
var wire []lbTopReleaseGroupWire
if err := json.Unmarshal(body, &wire); err != nil {
return nil, fmt.Errorf("listenbrainz top release groups unmarshal: %w", err)
}
const maxTopReleaseGroups = 10
limit := len(wire)
if limit > maxTopReleaseGroups {
limit = maxTopReleaseGroups
}
out := make([]LBTopReleaseGroup, limit)
for i := range limit {
out[i] = wire[i].toPublic()
}
c.cacheJSON(cacheKey, out, cacheTTLSearch, artistMBID, "artist")
return out, nil
}
// SimilarArtists returns artists similar to the one identified by
// artistMBID, using the ListenBrainz labs API. Returns nil, nil
// if the endpoint is unavailable (labs API may be unstable).
+42
View File
@@ -106,3 +106,45 @@ type LBSimilarArtist struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
// LBTopReleaseGroup represents a popular release group from the
// ListenBrainz popularity API.
type LBTopReleaseGroup struct {
ReleaseGroupMBID string `json:"releaseGroupMbid"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
Type string `json:"type"`
TotalListenCount int `json:"totalListenCount"`
}
// lbTopReleaseGroupWire matches the ListenBrainz API's snake_case
// JSON response for the popularity/top-release-groups-for-artist
// endpoint.
type lbTopReleaseGroupWire struct {
ReleaseGroupMBID string `json:"release_group_mbid"`
TotalListenCount int `json:"total_listen_count"`
ReleaseGroup struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"release_group"`
Artist struct {
Artists []struct {
Name string `json:"name"`
} `json:"artists"`
} `json:"artist"`
}
func (w lbTopReleaseGroupWire) toPublic() LBTopReleaseGroup {
artistName := ""
if len(w.Artist.Artists) > 0 {
artistName = w.Artist.Artists[0].Name
}
return LBTopReleaseGroup{
ReleaseGroupMBID: w.ReleaseGroupMBID,
Title: w.ReleaseGroup.Name,
ArtistName: artistName,
Type: w.ReleaseGroup.Type,
TotalListenCount: w.TotalListenCount,
}
}
@@ -5,6 +5,7 @@ import {
LookupArtist,
BrowseReleaseGroups,
TopRecordingsForArtist,
TopReleaseGroupsForArtist,
SimilarArtists,
GetArtistImageURL,
CheckLibraryMBIDs,
@@ -13,6 +14,7 @@ import type {
MBArtist,
MBReleaseGroup,
LBTopRecording,
LBTopReleaseGroup,
LBSimilarArtist,
} from '@go/explore/Service';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -81,9 +83,11 @@ export class ExploreArtistDetails extends LitElement {
@state() private artist: MBArtist | null = null;
@state() private topTracks: LBTopRecording[] = [];
@state() private topReleaseGroups: LBTopReleaseGroup[] = [];
@state() private releaseGroups: MBReleaseGroup[] = [];
@state() private loadingArtist = true;
@state() private loadingTracks = true;
@state() private loadingTopReleases = true;
@state() private loadingReleases = true;
@state() private errorArtist = '';
@state() private errorTracks = '';
@@ -330,18 +334,19 @@ export class ExploreArtistDetails extends LitElement {
.top-releases-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 10px;
align-content: start;
}
.top-release-card {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
flex-direction: column;
gap: 4px;
cursor: pointer;
transition: background 0.15s ease;
padding: 4px;
border-radius: 6px;
min-width: 0;
}
@@ -353,12 +358,12 @@ export class ExploreArtistDetails extends LitElement {
}
.top-release-card:active {
transform: scale(0.98);
transform: scale(0.97);
}
.top-release-art {
width: 44px;
height: 44px;
width: 100%;
aspect-ratio: 1;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(
@@ -366,7 +371,6 @@ export class ExploreArtistDetails extends LitElement {
var(--yj-bg-overlay, #404040) 0%,
var(--yj-bg-surface, #282828) 100%
);
flex-shrink: 0;
position: relative;
}
@@ -386,15 +390,14 @@ export class ExploreArtistDetails extends LitElement {
}
.top-release-art .album-art-fallback wa-icon {
font-size: 16px;
font-size: 20px;
color: var(--yj-text-tertiary, #888);
opacity: 0.5;
}
.top-release-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
text-align: center;
}
.top-release-title {
@@ -409,6 +412,7 @@ export class ExploreArtistDetails extends LitElement {
.top-release-meta {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs);
@@ -642,11 +646,12 @@ export class ExploreArtistDetails extends LitElement {
`[explore-artist] loading: "${this.artistName}" (${mbid})`,
);
// Fire all five requests in parallel — each section is independent.
const [artistResult, tracksResult, releasesResult, similarResult] =
// Fire all requests in parallel — each section is independent.
const [artistResult, tracksResult, topReleasesResult, releasesResult, similarResult] =
await Promise.allSettled([
this.fetchArtist(mbid),
this.fetchTopTracks(mbid),
this.fetchTopReleaseGroups(mbid),
this.fetchReleaseGroups(mbid),
this.fetchSimilarArtists(mbid),
]);
@@ -660,6 +665,7 @@ export class ExploreArtistDetails extends LitElement {
const summary = [
`artist=${artistResult.status}`,
`tracks=${tracksResult.status}`,
`topReleases=${topReleasesResult.status}`,
`releases=${releasesResult.status}`,
`similar=${similarResult.status}`,
].join(', ');
@@ -695,6 +701,21 @@ export class ExploreArtistDetails extends LitElement {
}
}
private async fetchTopReleaseGroups(mbid: string) {
try {
const rgs = await TopReleaseGroupsForArtist(mbid);
this.topReleaseGroups = rgs ?? [];
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[explore-artist] TopReleaseGroupsForArtist error: ${msg}`,
);
this.topReleaseGroups = [];
} finally {
this.loadingTopReleases = false;
}
}
private async fetchReleaseGroups(mbid: string) {
try {
const rgs = await BrowseReleaseGroups(mbid);
@@ -1001,19 +1022,10 @@ export class ExploreArtistDetails extends LitElement {
this.topSectionExpanded = !this.topSectionExpanded;
}
/** Top releases = all release groups sorted newest-first. */
private get topReleases(): MBReleaseGroup[] {
return [...this.releaseGroups].sort((a, b) => {
const da = a.firstReleaseDate || '';
const db = b.firstReleaseDate || '';
return db.localeCompare(da);
});
}
private renderTopSection() {
const hasTracks = !this.loadingTracks && this.topTracks.length > 0;
const hasReleases = !this.loadingReleases && this.releaseGroups.length > 0;
const isLoading = this.loadingTracks || this.loadingReleases;
const hasReleases = !this.loadingTopReleases && this.topReleaseGroups.length > 0;
const isLoading = this.loadingTracks || this.loadingTopReleases;
if (isLoading) {
return html`
@@ -1031,10 +1043,10 @@ export class ExploreArtistDetails extends LitElement {
const releaseLimit = expanded ? 8 : 4;
const tracks = this.topTracks.slice(0, trackLimit);
const releases = this.topReleases.slice(0, releaseLimit);
const releases = this.topReleaseGroups.slice(0, releaseLimit);
const canExpand =
this.topTracks.length > 5 || this.releaseGroups.length > 4;
this.topTracks.length > 5 || this.topReleaseGroups.length > 4;
return html`
<section>
@@ -1106,20 +1118,19 @@ export class ExploreArtistDetails extends LitElement {
`;
}
private renderTopReleaseCard(rg: MBReleaseGroup) {
const artURL = CoverArtGroupURL(rg.mbid);
const year = extractYear(rg.firstReleaseDate);
private renderTopReleaseCard(rg: LBTopReleaseGroup) {
const artURL = CoverArtGroupURL(rg.releaseGroupMbid);
return html`
<div
class="top-release-card"
@click=${() => this.navigateToAlbum(rg)}
@click=${() => this.navigateToTopRelease(rg)}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToAlbum(rg);
this.navigateToTopRelease(rg);
}
}}
>
@@ -1139,16 +1150,27 @@ export class ExploreArtistDetails extends LitElement {
${rg.title}
</div>
<div class="top-release-meta">
${this.libraryMBIDs.has(rg.mbid)
? html`<span class="library-badge">In Library</span>`
: nothing}
${year ? html`<span>${year}</span>` : nothing}
${rg.type ? html`<span>${rg.type}</span>` : nothing}
</div>
</div>
</div>
`;
}
private navigateToTopRelease(rg: LBTopReleaseGroup) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: rg.releaseGroupMbid,
albumName: rg.title,
},
}),
);
}
/* ── Discography Section ── */
private renderDiscography() {
+2
View File
@@ -50,3 +50,5 @@ export function StartIndexBuild():Promise<void>;
export function StopIndexBuild():Promise<void>;
export function TopRecordingsForArtist(arg1:string):Promise<Array<explore.LBTopRecording>>;
export function TopReleaseGroupsForArtist(arg1:string):Promise<Array<explore.LBTopReleaseGroup>>;
+4
View File
@@ -97,3 +97,7 @@ export function StopIndexBuild() {
export function TopRecordingsForArtist(arg1) {
return window['go']['explore']['Service']['TopRecordingsForArtist'](arg1);
}
export function TopReleaseGroupsForArtist(arg1) {
return window['go']['explore']['Service']['TopReleaseGroupsForArtist'](arg1);
}
+20
View File
@@ -34,6 +34,26 @@ export namespace explore {
this.totalListenCount = source["totalListenCount"];
}
}
export class LBTopReleaseGroup {
releaseGroupMbid: string;
title: string;
artistName: string;
type: string;
totalListenCount: number;
static createFrom(source: any = {}) {
return new LBTopReleaseGroup(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.releaseGroupMbid = source["releaseGroupMbid"];
this.title = source["title"];
this.artistName = source["artistName"];
this.type = source["type"];
this.totalListenCount = source["totalListenCount"];
}
}
export class MBArtist {
mbid: string;
name: string;