From cef6709d9ab430ad03573e651fcb8aa8bcac0f44 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 29 Mar 2026 17:12:30 -0400 Subject: [PATCH] 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. --- backend/explore/explore.go | 5 + backend/explore/listenbrainz.go | 46 ++++++++ backend/explore/types.go | 42 ++++++++ .../explore-artist-details.ts | 100 +++++++++++------- frontend/wailsjs/go/explore/Service.d.ts | 2 + frontend/wailsjs/go/explore/Service.js | 4 + frontend/wailsjs/go/models.ts | 20 ++++ 7 files changed, 180 insertions(+), 39 deletions(-) diff --git a/backend/explore/explore.go b/backend/explore/explore.go index f84c95f..bc2e2e4 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -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) diff --git a/backend/explore/listenbrainz.go b/backend/explore/listenbrainz.go index a80b01e..01268c8 100644 --- a/backend/explore/listenbrainz.go +++ b/backend/explore/listenbrainz.go @@ -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). diff --git a/backend/explore/types.go b/backend/explore/types.go index 51d7c8a..f3f0134 100644 --- a/backend/explore/types.go +++ b/backend/explore/types.go @@ -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, + } +} diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts index 6275d7e..b92d677 100644 --- a/frontend/src/components/explore-artist-details/explore-artist-details.ts +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -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`
@@ -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`
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}
- ${this.libraryMBIDs.has(rg.mbid) - ? html`In Library` - : nothing} - ${year ? html`${year}` : nothing} + ${rg.type ? html`${rg.type}` : nothing}
`; } + 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() { diff --git a/frontend/wailsjs/go/explore/Service.d.ts b/frontend/wailsjs/go/explore/Service.d.ts index 028dc17..d73cc78 100755 --- a/frontend/wailsjs/go/explore/Service.d.ts +++ b/frontend/wailsjs/go/explore/Service.d.ts @@ -50,3 +50,5 @@ export function StartIndexBuild():Promise; export function StopIndexBuild():Promise; export function TopRecordingsForArtist(arg1:string):Promise>; + +export function TopReleaseGroupsForArtist(arg1:string):Promise>; diff --git a/frontend/wailsjs/go/explore/Service.js b/frontend/wailsjs/go/explore/Service.js index f1a1f46..bd9b699 100755 --- a/frontend/wailsjs/go/explore/Service.js +++ b/frontend/wailsjs/go/explore/Service.js @@ -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); +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c6a8f4f..8a50381 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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;